1. 제네릭 소개
제네릭이란 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어 주는 기능이다. 제네릭 함수는 두루두루 모든 타입의 값을 다 적용할 수 있는 그런 범용적인 함수이다.
다음과 같이 제네릭 함수를 선언할 수 있다.
function func<T>(value: T): T {
return value;
}
let num = func(10);
// number 타입
함수 이름 뒤에 꺽쇠를 열고 타입을 담는 변수인 타입 변수 T를 선언하고 파라미터와 반환값의 타입을 T로 설정한다.
T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정된다. func(10) 처럼 Number 타입의 값을 아규먼트로 전달하면 파라미터 value에 Number 타입의 값이 저장되면서 T가 Number 타입으로 추론된다. 그럼 이때의 func 함수의 반환값 타입또한 Number 타입이 된다.
제네릭 함수를 호출할 때 다음과 같이 타입 변수에 할당할 타입을 직접 명시하는 것도 가능하다.
1. T에 [Number, Number, Number] 튜플 타입이 할당됨
2. 파라미터 value와 반환값 타입이 모두 튜플 타입이 됨
function func<T>(value: T): T {
return value;
}
let arr = func<[number, number, number]>([1, 2, 3]);
만약 위 코드에서 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T가 number[] 타입으로 추론 된다. 타입스크립트는 타입을 추론할 때 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문이다. 이렇듯 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해주면 된다.
2. 타입 변수 응용하기
1) 사례 1
만약 2개의 타입 변수가 필요한 상황이라면 다음과 같이 T, U 처럼 2개의 타입 변수를 사용해도 된다.
// T는 String 타입으로 U는 Number 타입
function swap<T, U>(a: T, b: U) {
return [b, a];
}
const [a, b] = swap("1", 2);
2) 사례 2
다양한 배열 타입을 아규먼트로 받는 제네릭 함수를 만들어야 한다면 다음과 같이 할 수 있다.
function returnFirstValue<T>(data: T[]) {
return data[0];
}
// 첫번째 호출에서는 아규먼트로 Number[] 타입의 값을 전달했으므로 이때의 T는 Number 타입으로 추론된다.
// 이때의 함수 반환값 타입은 Number 타입이 된다.
let num = returnFirstValue([0, 1, 2]);
// number
// 두번째 호출에서는 아규먼트로 (String | Number)[] 타입의 값을 전달했으므로 이때의 T는 String | Number 타입으로 추론된다.
// 이때의 함수 반환값 타입은 String | Number 타입이 된다.
let str = returnFirstValue([1, "hello", "mynameis"]);
// number | string
함수 매개변수 data의 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 아규먼트로 전달할 수 없게 된다. 배열을 아규먼트로 전달하면 T는 배열의 요소 타입으로 할당된다.
3) 사례 3
위 사례에서 만약 반환값의 타입을 배열의 첫번째 요소의 타입이 되도록 하려면 다음과 같이 튜플 타입과 나머지 파라미터를 이용한다.
//T는 첫번째 요소의 타입인 Number 타입이 된다. 따라서 함수 반환값 타입또한 Number 타입이 된다.
function returnFirstValue<T>(data: [T, ...unknown[]]) {
return data[0];
}
let str = returnFirstValue([1, "hello", "mynameis"]);
// number
함수 파라미터의 타입을 정의할 때 튜플 타입을 이용해 첫번째 요소의 타입은 T, 나머지 요소의 타입은 …unknown[] 으로 길이도 타입도 상관 없도록 정의한다.
4) 사례 4
마지막으로 살펴볼 사례는 타입 변수를 제한하는 사례이다. 타입 변수를 제한한다는 것은 함수를 호출하고 아규먼트로 전달할 수 있는 값의 범위에 제한을 두는 것을 의미한다.
다음은 타입 변수를 적어도 length 프로퍼티를 갖는 객체 타입으로 제한한 예시이다.
function getLength<T extends { length: number }>(data: T) {
return data.length;
}
getLength("123"); // ✅
getLength([1, 2, 3]); // ✅
getLength({ length: 1 }); // ✅
getLength(undefined); // ❌
getLength(null); // ❌
타입 변수를 제한할 때에는 확장(extends)을 이용한다.
위와 같이 T extends { length : number } 라고 정의하면, T는 이제 { length : number } 객체 타입의 서브 타입이 된다. 바꿔말하면 이제 T는 무조건 Number 타입의 프로퍼티 length 를 가지고 있는 타입이 되어야 한다는 것이다. 따라서 이렇게 extends를 이용해 타입 변수를 제한하면 아래와 같은 결과가 나타납니다.
- 1번 호출은 인수로 length 프로퍼티가 존재하는 String 타입의 값을 전달 했으므로 허용
- 2번 호출은 인수로 length 프로퍼티가 존재하는 Number[] 타입의 값을 전달 했으므로 허용
- 3번 호출은 인수로 length 프로퍼티가 존재하는 객체 타입의 값을 전달 했으므로 허용
- 4번 호출은 인수로 undefined을 전달했으므로 오류
- 5번 호출은 인수로 null을 전달했으므로 오류
3. map, forEach 메서드 타입 정의하기
1) map
map 메서드는 원본 배열 타입과 다른 타입의 배열로도 변환할 수 있어야 한다.
const arr = [1, 2, 3];
function map<T>(arr: T[], callback: (item: T) => T): T[] {
(...)
}
map(arr, (it) => it.toString()); // ❌
따라서 타입 변수를 하나 더 추가해 다음과 같이 수정한다.
const arr = [1, 2, 3];
function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
(...)
}
map(arr, (it) => it.toString());
// string[] 타입의 배열을 반환
// 결과 : ["1", "2", "3"]
2) forEach
function forEach<T>(arr: T[], callback: (item: T) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i]);
}
}
4. 제네릭 인터페이스, 제네릭 타입 별칭
제네릭은 인터페이스에도 적용할 수 있다. 다음과 같이 인터페이스에 타입 변수를 선언해 사용하면 된다.
interface KeyPair<K, V> {
key: K;
value: V;
}
let keyPair: KeyPair<string, number> = {
key: "key",
value: 0,
};
let keyPair2: KeyPair<boolean, string[]> = {
key: true,
value: ["1"],
};
제네릭 인터페이스는 제네릭 함수와는 달리 변수의 타입으로 정의할 때 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 한다.제네릭 함수는 파라미터에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있지만, 인터페이스는 마땅히 추론할 수 있는 값이 없기 때문이다.
# 인덱스 시그니쳐와 함께 사용하기
제네릭 인터페이스는 인덱스 시그니쳐와 함께 사용하면 다음과 같이 기존보다 훨씬 더 유연한 객체 타입을 정의할 수 있다.
interface Map<V> {
[key: string]: V;
}
//변수 stringMap의 타입을 Map<string> 으로 정의했다. 따라서 V가 string 타입이 되어
// 이 변수의 타입은 key는 string이고 value는 string인 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.
let stringMap: Map<string> = {
key: "value",
};
let booleanMap: Map<boolean> = {
key: true,
};
한개의 타입 변수 V를 갖는 제네릭 인터페이스 Map을 정의했다. 이 인터페이스는 인덱스 시그니쳐로 key의 타입은 string, value의 타입은 V인 모든 객체 타입을 포함하는 타입이다.
# 제네릭 타입 별칭
인터페이스와 마찬가지로 타입 별칭에도 역시 제네릭을 적용할 수 있다.
type Map2<V> = {
[key: string]: V;
};
let stringMap2: Map2<string> = {
key: "string",
};
제네릭 타입 별칭을 사용할 때에도 제네릭 인터페이스와 마찬가지로 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야 한다.
# 제네릭 인터페이스 활용 예
개발자 또는 학생이 이용하는 어떤 프로그램이 있다고 가정해보자.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
interface User {
name: string;
profile: Student | Developer;
}
function goToSchool(user: User<Student>) {
if (user.profile.type !== "student") {
console.log("잘 못 오셨습니다");
return;
}
const school = user.profile.school;
console.log(`${school}로 등교 완료`);
}
const developerUser: User = {
name: "이정환",
profile: {
type: "developer",
skill: "typescript",
},
};
const studentUser: User = {
name: "홍길동",
profile: {
type: "student",
school: "가톨릭대학교",
},
};
학생을 의미하는 Student와 개발자를 의미하는 Developer 타입, 이 두 타입 모두 String Literal 타입의 type 프로퍼티를 갖고 있으며, 서로소 유니온 타입이다.
위 코드는 겉으로 보았을때에는 지금 당장은 별 문제가 없어보이지만 학생만 할 수 있는 기능이 점점 많아진다고 가정하면 매번 기능을 만들기 위해 함수를 선언할 때 마다 조건문을 이용해 타입을 좁혀야 하기 때문에 결국 매우 불편해 질 것이다. 게다가 타입을 좁히는 코드는 중복 코드가 될 것이다.
이럴 때 바로 제네릭 인터페이스를 이용하면 좋다.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
interface User<T> {
name: string;
profile: T;
}
function goToSchool(user: User<Student>) {
const school = user.profile.school;
console.log(`${school}로 등교 완료`);
}
const developerUser: User<Developer> = {
name: "이정환",
profile: {
type: "developer",
skill: "TypeScript",
},
};
const studentUser: User<Student> = {
name: "홍길동",
profile: {
type: "student",
school: "가톨릭대학교",
},
};
그럼 이제 goToSchool 함수의 파라미터 타입을 User<Student> 처럼 정의해 학생 유저만 이 함수의 아규먼트로 전달하도록 제한할 수 있다. 결과적으로 함수 내부에서 타입을 좁힐 필요가 없어지므로 코드가 훨씬 간결해진다.
5. 프로미스와 제네릭
Promise는 제네릭 클래스로 구현되어 있어 새로운 Promise를 생성할 때 다음과 같이 타입 변수에 할당할 타입을 직접 설정해 주면 해당 타입이 바로 resolve 결과값의 타입이 된다.
const promise = new Promise<number>((resolve, reject) => {
setTimeout(() => {
// 결과값 : 20
resolve(20);
}, 3000);
});
promise.then((response) => {
// response는 number 타입
console.log(response);
});
promise.catch((error) => {
if (typeof error === "string") {
console.log(error);
}
});
reject 함수에 아규먼트로 전달하는 값, 즉 실패의 결과값 타입은 정의할 수 없다. 그냥 unknown 타입으로 고정되어 있기 때문에 catch 메서드에서 사용하려면 타입 좁히기를 통해 안전하게 사용해야 한다.
만약 어떤 함수가 Promise 객체를 반환한다면 함수의 반환값 타입을 위해 다음과 같이 할 수 있다.
function fetchPost() {
return new Promise<Post>((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 본문",
});
}, 3000);
});
}
// 이 방식이 협업에 더 좋다.
// 이 함수는 Promise로 Post 타입을 반환한다는 뜻이된다.
function fetchPost(): Promise<Post> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 본문",
});
}, 3000);
});
}
'온라인 강의(유데미, 인프런 등) > 한 입 크기로 잘라먹는 타입스크립트(인프런)' 카테고리의 다른 글
[한 입 크기로 잘라먹는 타입스크립트] 조건부 타입 (0) | 2023.06.20 |
---|---|
[한 입 크기로 잘라먹는 타입스크립트] 타입 조작하기 (0) | 2023.06.20 |
[한 입 크기로 잘라먹는 타입스크립트] 인터페이스 (0) | 2023.06.19 |
[한 입 크기로 잘라먹는 타입스크립트] 함수 타입 (0) | 2023.06.17 |
[한 입 크기로 잘라먹는 타입스크립트] 타입스크립트 이해하기 (0) | 2023.06.17 |