섹션 7. 제네릭
1) 제네릭이란
제네릭 타입은 타입에 유연성을 제공하여 재사용을 가능하게 해주는 타입이다. 타입 정보가 동적으로 결정된다.
즉, 선언 시점이 아니라 생성 시점에 타입을 명시하여 하나의 타입만이 아닌 다양한 타입을 사용할 수 있도록 하여 한번의 선언으로 다양한 타입에 재사용이 가능하다. () 제네릭을 사용하면 함께 작동하는 데이터 구조를 만들거나 다양한 타입의 값을 래핑할 수 있다.)
1
2
3
4
5
//배열타입 생성
const names = ["Max", "Manuel"];
// 에러 발생
const nameA: Array = []
Array라는 타입을 지정하려고 하니, 이는 제네릭 타입이고 하나의 인수가 필요하다고 말하고 있다. 배열은 그 자체로 타입이지만, 배열에 특정 타입의 데이터를 저장할 수 있다. 배열타입은 어떤 타입의 데이터가 저장되든 상관하지 않지만 적어도 정보가 저장되는 것인지에 대해 확인을 하기 때문이다.
1
2
// 홑화살 괄호 내에 배열에 전달되어야 하는 데이터의 타입을 지정한다.
const nameB: Array<string> = []; // string[]과 같다.
이렇게 <>
사이에 타입을 지정해주는 것을 제네릭이라고 한다. 배열의 경우에는 배열 내부에 존재하는 타입에 대해서 설정했지만, 타입마다 이는 다르다. 예를 들어 Promise 타입을 살펴보자.
1
2
3
4
5
6
// 문자열을 반환한다.
const promise: Promise<string> = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("This is done!");
}, 2000);
});
배열은 특정 타입의 데이터를 저장하기 때문에 해당 내용을 표현을 하게 되고 프로미스는 특정 타입의 데이터를 반환하기 때문에 해당 타입을 <>
안에 기입한다.
2) 제네릭 함수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function merge(objA: object, objB: object) {
return Object.assign(objA, objB)
};
const mergedObjA = merge({name: "MAX"}, {age:30})
// 접근할 수 없음: object를 반환하지만 name속성이 있는지 모르기때문에
mergedObjA.name
// 그래서 as를 써서 정의를 할수 있지만, 번거롭다.
const mergedObjB = merge({name: "MAX"}, {age:30} as {name: string, age: number})
// 접근 가능
mergedObjB.name
mergedObjA
와 같은 상황이 발생하는 이유는 타입스크립트는 객체를 반환하는 것을 추론하지만, 정확히 어떤 정보를 담고 있는지 모르기 때문에 상세히 알려 주어야 하기 때문이다. 해당 객체에서 사용할 수 있는 정보를 모두 담고 있지 않기 때문에 우리는 번거롭게 정보를 자세히 전달해야 한다. 이럴 때 제네릭을 사용하여 타입을 동적으로 할당하여 유연하게 사용할 수 있다.
1
2
3
4
5
6
function merge<T extends object, U extends object>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const mergedObjB = merge({ name: 'Max', hobbies: ['Sports'] }, { age: 30 });
console.log(mergedObj);
제네릭 타입을 사용하면 이 두 매개변수가 종종 서로 다른 타입이 될 수 있다고 타입스크립트에 알려줄 수 있으므로 무작위의 객체 타입으로 작업하는 것이 아닌 다양한 타입 데이터를 얻고자 한다는 것을 타입스크립트가 인식하게 된다. 또한, 이 함수는 T와 U의 인터섹션을 반환한다고 추론한다. 즉, 이 함수를 정의할 때 이러한 타입들이 고정적으로 설정되지 않고 함수를 호출할 때 동적일 수 있도록 설정한 것이다.
이렇게 된다면, mergeObjB
는 name: string과 age: number 객체로 전달한다는 것과 함수가 인터섹션을 반환한다는 것을 알게 된다.
- T: 객체 타입을 문자열을 지닌 name 속성이 있는 객체로 hobbie 속성은 문자열의 배열로 작성
- U: 숫자형 타입인 age 속성을 지닌 객체 타입으로 작성
그리고 반환 값인 T&U
이기 때문에 해당 객체들의 모든 속성에 접근할 수 있게 된다.
(1) 타입 제약 조건
불필요한 에러나 이상한 작동을 방지하여 최적의 방식으로 제네릭 타입을 제한 하는 것
1
2
3
4
5
6
7
8
// 타입을 제한한다.
function merge<T extends object, U extends object>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const mergedObj = merge({ name: 'Max', hobbies: ['Sports'] }, { age: 30 });
console.log(mergedObj);
Object.assign
때문에 매개변수는 객체만 올 수 있기 때문에 제네릭 타입에 특정한 제약 조건을 설정하므로써 객체만 매개변수로 전달될 수 있도록 할 수 있다. 제약 조건에는 무엇이든 지정이 가능하다. 객체, 문자열, 직접 만든 타입, 유니언 타입등 다양한 제약조건을 추가할 수도 있고 U
에만 제약을 설정할 수도 있다. 유연하게 제약을 걸 수 있기 때문에 유용한 기능이다.
(2) 속성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Lengthy {
length: number;
}
function countAndDescribe<T extends Lengthy>(element: T): [T, string] {
let descriptionText = 'Got no value.';
if (element.length === 1) {
descriptionText = 'Got 1 element.';
} else if (element.length > 1) {
descriptionText = 'Got ' + element.length + ' elements.';
}
return [element, descriptionText];
}
console.log(countAndDescribe(['Sports', 'Cooking']));
// 에러 발생: number는 length속성이 없기 때문이다.
console.log(countAndDescribe(3));
타입의 속성에 접근할 때, 해당 속성이 있는지 여부를 파악하기 위해서 타입 제약 조건을 활용할 수 있다. length
속성의 경우 문자열이나 배열의 타입에 있는 속성이다. 그러나 제네릭을 사용하면 무작위로 타입을 사용할 수 있기 때문에, length속성을 사용할 수 있는 타입스크립트는 알 수 없다. 그래서 타입스크립트에게 우리는 “length
속성이 있는 타입을 사용할 거야~”라고 말해 줄 수 있다.
이처럼 보다 유연한 작업이 요구될 때 제네릭 유형을 사용하면 제약 조건 덕분에 정확한 타입에 대해 신경 쓰지 않고 length 속성이 있는지만 신경 쓰면 된다.
(3) keyof
1
2
3
4
// 에러 발생: 여기 입력한 객체가 무엇이든 이 키를 가지는 지 모르기 때문
function extractAndConvert(obj:object, key:string){
return obj[key];
}
타입스크립트가 이 객체에 이 키가 있는지 보장할 수 없기 때문에 에러가 발생한다. 이를 보장하려면 제네릭 타입을 사용하면 된다.
1
2
3
4
5
function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
return 'Value: ' + obj[key];
}
extractAndConvert({ name: 'Max' }, 'name');
key of
를 활용하여 U
타입은 T
타입의 key인 것을 명시적으로 알려 줄 수 있다. 즉, keyof 키워드를 지니는 제네릭 타입을 사용하여 정확한 구조를 타입스크립트에게 알려주어 실수를 하지 않도록 도와준다.
3) 제네릭 클래스
클래스를 정의할 때도 메서드나 속성의 타입을 나중에 확정할 수 있다. 즉, 인스턴스를 호출할 때 정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 원시 타입으로 제한했다.
class DataStorage<T extends string | number | boolean> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
if (this.data.indexOf(item) === -1) {
return;
}
this.data.splice(this.data.indexOf(item), 1); // -1
}
getItems() {
return [...this.data];
}
}
const textStorage = new DataStorage<string>();
textStorage.addItem('Max');
textStorage.addItem('Manu');
textStorage.removeItem('Max');
console.log(textStorage.getItems());
const numberStorage = new DataStorage<number>();
이렇게 되면 한 가지 타입으로만 국한되어 클래스 작업을 하지 않아도 된다. 이처럼 데이터의 타입이 무엇이든 상관없다면 제네릭을 통해 유연하게 코드를 작성할 수 있다.
4) 제네릭 유틸리티 타입
TypeScript는 일반적인 타입 변환을 쉽게 하기 위해서 몇 가지 유틸리티 타입을 제공한다.
(1) partial
모든 프로퍼티를 선택적으로 타입을 생성한다. (특정 타입의 부분 집합을 만족하는 타입을 정의할 수 있다. )
1
2
3
4
5
6
7
8
9
10
11
12
13
interface CourseGoal {
title: string;
description: string;
completeUntil: Date;
}
function createCourseGoal( title: string, description: string, date: Date): CourseGoal {
let courseGoal: Partial<CourseGoal> = {}; // courseGoal의 타입의 속성을 선택적으로 갖는 객체이다.
courseGoal.title = title;
courseGoal.description = description;
courseGoal.completeUntil = date;
return courseGoal as CourseGoal; // 반환할 때, courseGoal로 형 변환하여 반환한다.
}
courseGoal
이 partial 타입이어야 한다고 설정하면 제네릭 타입 덕분에 결과적으로 courseGoal 타입
을 지니게 된다. 이렇게 객체나 인터페이스의 속성을 일시적으로 선택적이 되어야 하는 경우가 발생하면, partial타입으로 구현할 수 있다.
(2) Readonly
모든 속성이
읽기 전용(readonly)
으로 설정한 타입을 생성한다, 즉 생성된 타입의 프로퍼티는 재할당될 수 없다.
1
2
3
4
5
const names: Readonly<string[]> = ["Max", "Anna"];
// 에러!!
names.push('Manu');
names.pop();