1. 제네릭이란
제네릭이란 함수, 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어 주는 기능이다.
1.1 제네릭은 언제 필요할까
function func(value: any) {
return value;
}
let num = func(10);
// any 타입
let str = func("string");
// any 타입
num.toUpperCase();
- func 함수는 인수로 전달한 값을 그대로 반환하는 함수이다. 즉, 10을 전달하면 10이 "string"을 전달하면 "string"이 저장된다.
- 하지만 func 함수는 any 타입이 된다. 왜냐하면 반환값 타입이 return 문을 기준으로 추론되었지 때문이다.
- 이때 10 숫자가 저장되어있는 num에 toUpperCase와 같은 문자열 메서드를 사용해도 오류를 감지하지 못하는 일이 발생한다.
function func(value: unknown) {
return value;
}
let num = func(10);
// unknown 타입
let str = func("string");
// unknown 타입
num.toUpperCase(); // ❌
num.toFixed(); // ❌
// 타입 좁히기
if (typeof num === "number") {
num.toFixed();
}
- 따라서 이처럼 다시 unknown 타입으로 정의하면 toUpperCase의 오류는 잡아내지만 오류가 나지 않아야할 toFixed 메서드에서도 오류가 나는일이 발생한다. 이는 조건문을 사용한 타입 좁히기로 해결할 수 있다.
☞ 해결할 수 있지만 간단하게 해결할 수 있는일이 복잡해지는 느낌이 있다.
즉, 인수로 number 타입의 값을 전달하면 반환값 타입이 number가 되고 string 타입을 전달하면 반환값 타입이 string이 되었으면 한다. 이때 제네릭을 사용할 수 있다.
1.2 제네릭(Generic) 함수
제네릭 함수는 모든 타입의 값을 다 적용할 수 있는 범용적인 함수다.
function func<T>(value: T): T {
return value;
}
let num = func(10);
// number 타입
- 함수 이름뒤에 꺽쇠를 열고 타입을 담는 타입 변수 T를 선언한다.
- 매개변수와 반환값의 타입을 이 타입 변수 T로 설정한다.
- T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정된다.
- 즉, 인수로 numver 타입의 값을 전달하면 매개변수 value에 number 타입의 값이 저장되면서 T가 number 타입으로 추론된다.
function func<T>(value: T): T {
return value;
}
let arr = func<[number, number, number]>([1, 2, 3]);
- 제네릭 함수를 호출할 때 타입 변수에 할당할 타입을 직접 명시하는것도 가능하다.
- T에 [number, number, number] 튜플 타입이 할당되어있다.
- 매개변수 value와 반환값 타입이 모두 튜플 타입이 되었다.
- 만약 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T는 number[] 타입으로 추론되었을 것이다. number[] 타입이 좀 더 일반적이로 범용적인 타입이기 때문이다.
- 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해주면 된다.
2. 타입 변수 응용하기
1) 만약 2개의 타입 변수가 필요한 상황이라면?
function swap<T, U>(a: T, b: U) {
return [b, a];
}
const [a, b] = swap("1", 2);
- T, U 처럼 2개의 타입 변수를 사용해도 된다.
- T는 string 타입 U는 number 타입으로 추론된다.
2) 다양한 배열 타입을 인수로 받는 제네릭 함수를 만들어야 된다면?
function returnFirstValue<T>(data: T[]) {
return data[0];
}
let num = returnFirstValue([0, 1, 2]);
// number
let str = returnFirstValue([1, "hello", "mynameis"]);
// number | string
- 함수 매개변수 data 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 인수로 전달할 수 없다.
- 배열을 인수로 전달하면 T는 배열의 요소 타입으로 할당된다.
3) 만약 반환값의 타입을 배열의 첫번째 요소의 타입이 되도록 하려면?
function returnFirstValue<T>(data: [T, ...unknown[]]) {
return data[0];
}
let str = returnFirstValue([1, "hello", "mynameis"]);
// number
- 튜플타입과 나머지 파라미터를 이용하면 된다.
4) 타입 변수를 제한하려면?
function getLength<T extends { length: number }>(data: T) {
return data.length;
}
getLength("123"); // ✅
getLength([1, 2, 3]); // ✅
getLength({ length: 1 }); // ✅
getLength(undefined); // ❌
getLength(null); // ❌
- 타입 변수를 제한한다는 것은 함수를 호출하고 인수로 전달할 수 있는 값의 범위에 제한을 두는 것을 의미한다.
- 위의 예시는 타입 변수를 length 프로퍼티를 갖는 객체 타입으로 제한한 예시이다.
- 타입 변수를 제한할 때는 확장(extends)를 이용한다.
- T extends { length: number } 라고 정의하면 T는 { length: number } 객체 타입의 서브 타입이된다. 즉, T는 무조건 number 타입의 프로퍼티 length를 가지고 있는 타입이 되어야한다.
3. map, forEach 메서드 타입 정의하기
3.1 map 이란?
const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2);
// [2, 4, 6]
- map은 원본 배열의 각 요소에 콜백함수를 수행하고 반환된 값들을 모아 새로운 배열로 만들어 반환한다.
3.2 map 메서드 타입 정의하기
function map(arr: unknown[], callback: (item: unknown) => unknown): unknown[] {}
일반 함수로 만들기
- 메서드를 적용할 배열를 매개변수 arr로 받고 콜백 함수를 매개변수 callback으로 받는다.
- map 메서드는 모든 타입의 배열에 적용할 수 있기 때문에 arr 타입은 unknown[]으로 정의한다.
- callback 타입은 배열 요소 하나를 매개변수로 받아 특정 값을 반환하는 함수로 정의한다. → 함수 표현식 사용
- map 메서드의 반환값의 타입은 배열타입으로 정의한다.
function map<T>(arr: T[], callback: (item: T) => T): T[] {
let result = [];
for (let i = 0; i < arr.length; i++) {
result.push(callback(arr[i]));
}
return result;
}
map(arr, (it) => it * 2);
// number[] 타입의 배열을 반환
// 결과 : [2, 4, 6]
제네릭 함수로 만들기
- 일반 함수에서 정의했던 unknown 타입을 타입 변수 T로 대체한다.
- 함수 내부를 구현하고 함수를 호출한다.
- 매개변수 arr에 number[] 타입의 배열을 제공하니 타입변수 T가 number로 추론되고 그 결과 map 함수의 반환값 타입도 number[]가 되었다.
const arr = [1, 2, 3];
function map<T>(arr: T[], callback: (item: T) => T): T[] {
(...)
}
map(arr, (it) => it.toString()); // ❌
// 타입 변수 추가
function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
(...)
}
map(arr, (it) => it.toString());
// string[] 타입의 배열을 반환
// 결과 : ["1", "2", "3"]
- 콜백함수가 모든 배열 요소로 string 타입으로 변환하도록 수정하면 오류가 발생한다.
- 첫 번째 인수로 arr을 전달했을 때 타입 변수 T에는 number 타입이 할당되었기 때문이다.
- map 메서드는 원본 배열 타입과 다른 타입의 배열로도 변환할 수 있어야한다. 따라서 타입 변수를 하나 더 추가해야한다.
- 원본 배열의 타입과 새롭게 반환하는 배열의 타입을 다르게 설정해주면 된다.
3.3 forEach 란?
const arr2 = [1, 2, 3];
arr2.forEach((it) => console.log(it));
// 출력 : 1, 2, 3
- 배열의 모든 요소에 콜백함수를 한번씩 수행해주는 메서드이다.
3.4 forEach 메서드 타입 정의하기
function forEach<T>(arr: T[], callback: (item: T) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i]);
}
}
- 2개의 매개변수를 받는다.
- 첫 번째 매개변수 arr에는 순회 대상 배열을 제공받는다.
- 두 번째 매개변수 callback에는 모든 배열 요소에 수행할 함수를 제공받는다.
- map 메서드와 달리 반환값이 없는 메서드 이므로 콜백 함수의 반환값 타입을 void로 정의한다.
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"],
};
- 제네릭은 인터페이스에도 적용할 수 있는데 인터페이스 타입 변수를 선언해 사용하면 된다.
- 제네릭 인터페이스는 제네릭 함수와 달리 변수의 타입으로 정의할 때 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해줘야한다. → 제네릭 함수는 매개변수에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있지만 인터페이스는 추론할 값이 없기 때문!
4.1 인덱스 시그니쳐와 함께 사용하기
interface Map<V> {
[key: string]: V;
}
let stringMap: Map<string> = {
key: "value",
};
let booleanMap: Map<boolean> = {
key: true,
};
- 제네릭 인터페이스는 인덱스 시그니쳐와 함께 사용하면 훨씬 더 유연한 객체 타입을 정의할 수 있다.
- 타입 변수 V에 원하는 타입으로 정의해주면 V는 해당 타입이되어 그 타입의 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.
4.2 제네릭 인터페이스 활용하기
개발자 또는 학생이 이용하는 어떤 프로그램이 있다고 가정해보자.
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 타입은 모두 strung literal 타입의 type 프로퍼티를 갖고 있으며, 서로소 유니온 타입이다.
- User 타입은 학생일수도 개발자 일수도 있는 타입이다. 만약 학생이라면 profile 프로퍼티에 Student 타입의 객체가 저장될 것이고, 그렇지 않다면 Developer 타입의 객체가 저장될 것이다.
- goToSchool은 학생 유저만 이용할 수 있는 함수이며 User 타입의 객체를 받아 타입을 좁혀 이 유저가 학생일 때만 "등교 완료"를 콘솔에 출력한다.
하지만 위 코드는 학생만 할 수 있는 기능이 점점 많아진다고 가정하면 매번 기능을 만들기 위해 함수를 선언할 때 마다 조건문을 이용해 타입을 좁혀야 하기 때문에 중복코드가 많아지고 불편해진다. 이때 제네릭 인터페이스를 이용하면 좋다.
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. 제네릭 타입 별칭
type Map2<V> = {
[key: string]: V;
};
let stringMap2: Map2<string> = {
key: "string",
};
- 제네릭 타입 별칭을 사용할 때에도 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해줘야 한다.
6. 제네릭 클래스
class NumberList {
constructor(private list: number[]) {}
push(data: number) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new NumberList([1, 2, 3]);
- number 타입의 리스트를 생성하는 클래스
- list 필드를 private(접근 제어자)로 설정해 클래스 내부에서만 접근할 수 있도록 만들고 생성자에서 필드 선언과 함께 초기화한다.
- 새로운 요소를 추가하는 push, 제거하는 pop, 출력하는 print 메서드가 있다.
- 새로운 클래스를 만들어야 한다면 추가로 하나 더 만들어줘야 하기때문에 새롭게 추가되거나 수정되면 매우 비효율적이다.
이때 제네릭 클래스를 사용해 여러 타입의 리스트를 생성할 수 있는 클래스를 정의하면 된다.
class List<T> {
constructor(private list: T[]) {}
push(data: T) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new List([1, 2, 3]);
const stringList = new List(["1", "2"]);
- 클래스의 이름뒤에 타입 변수를 선언하면 제네릭 클래스가 된다.
- 이 타입 변수는 클래스 내부에서 자유롭게 사용할 수 있다.
- 클래스는 생성자를 통해 타입 변수의 타입을 추론할 수 있기 때문에 인수로 전달하는 값이 있을 경우 타입 변수에 할당할 타입을 생략해도된다.
7. 프로미스와 제네릭
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);
}
});
- Propmise는 제네릭 클래스로 구현되어있다.
- 새로운 Promise를 생성할 때 타입 변수에 할당할 타입을 직접 설정해 주면 해당 타입이 바로 resolve 결과값의 타입이 된다.
- reject 함수 인수로 전달하는 값, 즉 실패의 결과값 타입은 정의할 수 없다. →unknown타입으로 고정
- catch 메서드에서 사용하려면 타입 좁히기를 통해 사용하는걸 권장한다.
📌
'Front-end > TypeScript' 카테고리의 다른 글
[TS] 조건부 타입 (0) | 2024.02.22 |
---|---|
[TS] 타입 조작하기 (0) | 2024.02.22 |
[TS] 함수와 타입 (0) | 2024.02.20 |
[TS] 타입을 추론하고... 단언하고... 좁히고... (0) | 2024.02.19 |
[TS] 타입 별칭(type) vs 인터페이스(interface) (1) | 2024.02.18 |