CHAPTER 10. 제네릭
1
2
타입 시스템에서 선언된 변수는
왼전히 새롭게 타입된 세계가 됩니다!
- 타입스크립트는 제네릭을 사용해 타입 간의 관계를 알아낸다.
- 타입스크립트에서 함수와 같은 구조체는 제네릭 타입 매개변수를 원하는 수만큼 선언할 수 있다.
- 제네릭 타입 매개변수는 제네릭 구조체의 각 사용법에 따라 타입이 결정된다.
- 이러한 타입 매개변수는 구조체의 각 인스턴스에서 서로 다른 일부 타입을 나타내기 위해 구조체의 타입으로 사용된다.
- 타입 매개변수는 구조체의 각 인스턴스에 대해 타입 인수라고 하는 서로 다른 타입을 함께 제공할 수 있지만, 타입 인수가 제공된 인스턴스 내에서는 일관성을 유지한다.
- 타입 매개변수는 전형적으로
T
나U
같은 단일 문자 이름 또는Key
와Value
같은 파스칼 케이스 이름을 갖는다.
🌓 제네릭 함수
- 매개변수 괄호 바로 앞 홑화살괄호(
<, >
)로 묶인 타입 매개변수에 별칭을 배치해 함수를 제네릭으로 만든다. - 그러면 해당 타입 매개변수를 함수 본문 내부의 매개변수 타입 애너테이션, 반환 타입 애너테이션, 타입 애너테이션, 타입 애너테이션에서 사용할 수 있다.
[예제]
- identity 함수는 input 매개변수에 대한 타입 매개변수 T를 선언한다.
- 이를 통해 타입스크립트는 함수의 반환 타입이 T임을 유추한다.
- 그러면 타입스크립트는 identity가 호출될 때마다 T에 대한 다른 타입을 유추할 수 있다.
1
2
3
4
5
6
function identity<T>(input: T) {
return input;
}
const numeric = identity('me'); // 타입: "me"
const stringy = identity(123); // 타입: 123
화살표 함수도 제네릭을 만들 수 있으며, 화살표 함수의 제네릭 선언은 매개변수 목록 바로 전인 (
앞에 위치한다.
1
2
3
4
// 위와 동일한 함수 선언식
const identity = <T>(input: T) => input;
identity(123); // 타입: 123
🌿 제네릭 화살표 함수 구문은
.tsx
파일에서 JSX 구문과 충돌하므로 일부 제한이 있다.
이러한 방식으로 함수에 타입 매개변수를 추가하면 타입 안정성을 유지하고 any 타입을 피하면서 다른 입력과 함께 재사용할 수 있다.
1. 명시적 제네릭 호출 타입
- 제네릭 함수를 호출할 때 대부분의 타입스크립트는 함수가 호출되는 방식에 따라 타입 인수를 유추한다.
- 예를 들어, 이전 예전의 identity 함수에서 타입스크립트의 타입 검사기는 identity에 제공된 인수를 사용해 해당 함수 매개변수의 타입 인수를 유추한다.
- 하지만 클래스 멤버와 변수 타입과 마찬가지로 때는 타입 인수를 해석하기 위해 타입스크립트에 알려줘야 하는 함수 호출 정보가 충분하지 않을 수도 있다.
- 이러한 현상은 타입 인수를 알 수 없는 제네릭 구문에 제공된 경우 주로 발생한다.
- 기본값이
unknown
으로 설정되는 것을 피하기 위해 타입스크립트에 해당 타입 인수가 무엇인지 명시적으로 알려주는 명시적 제네릭 타입 인수를 사용해 함수를 호출할 수 있다. - 타입스크립트는 매개변수가 타입 인수로 제공된 것과 일치하는지 확인하기 위해 제네릭 호출에서 타입 검사를 수행한다.
- 변수에 대한 명시적 타입 애너테이션과 마찬가지로 명시적 타입 인수는 항상 제네릭 함수에 지정할 수 있지만 때로는 필요하지 않는다.
2. 다중 함수 타입
- 임의의 수의 타입 매개변수를 쉼표로 구분해 함수를 정의한다.
- 제네릭 함수의 각 호출은 각 타입 매개변수에 대한 자체 값 집합을 확인할 수 있다.
[예제]
makeTuple은 두 개의 타입 매개변수를 선언하고 입력된 값을 읽기 전용 튜플로 반환한다.
1
2
3
function makeTuple<First, Second>(first: First, second: Second) {
return [first, second] as const;
}
- 함수가 여러 개의 타입 매개변수를 선언하면 해당 함수에 대한 호출은 명시적으로 제네릭 타입을 모두 선언하지 않거나 모두 선언해야한다.
- 타입스크립트는 아직 제네릭 호출 중 일부 타입만을 유추하지는 못한다.
🌿 제네릭 구조체에서 두 개보다 많은 타입 매개변수를 사용하지 말아야한다. 런타임 함수 매개변수처럼 많이 사용할수록 코드를 읽고 이해하는 것어 점점 어려워진다.
🌓 제네릭 인터페이스
- 인터페이스도 제네릭으로 선언할 수 있다.
- 인터페이스는 함수와 유사한 제네릭 규칙을 따르며 인터페이스 이름 뒤
<
과>
사이에 선언된 임의의 수의 타입 매개변수를 갖는다. - 해당 제네릭 타입은 나중에 멤버 타입과 같이 선언의 다른 곳에서 사용할 수 있다.
타입스크립트에서 내장 Array
메서드는 제네릭 인터페이스로 정의된다.
- Array는 타입 매개변수 T를 사용해서 배열 안에 저장된 데이터 타입을 나타낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface Array<T> {
// ...
/**
* 배열에서 마지막 요소를 제거하고 그 요소를 반환
* 배열이 비어있는 경우 undefined를 반환하고 베열은 수정되지 않음
*/
pop(): T | undefined;
/**
* 배열의 끝에 새로운 요소를 추가하고 배열의 길이를 반환
* @param items 배열에 추가된 새로운 요소
*/
push(...items: T[]): number;
// ...
}
유추된 제네릭 인터페이스 타입
- 제네릭 함수와 마찬가지로 제네릭 인터페이스의 타입 인수는 사용법에서 유추할 수 있다.
- 타입스크립트는 제네릭 타입을 취하는 것으로 선언된 위치에서 제공된 값의 타입에서 타입 인수를 유추한다.
- 인터페이스가 타입 매개변수를 선언하는 경우, 해당 인터페이스를 참조하는 모든 타입 애너테이션은 이에 상응하는 타입 인수를 제공해야 한다.
🌓 제네릭 클래스
- 인터페이스처럼 클래스도 나중에 멤버에서 사용할 임의의 수의 타입 매개변수를 선언할 수 있다.
- 클래스의 각 인스턴스는 타입 매개변수로 각자 다른 타입 인수 집합을 가진다.
[예제]
Secret 클래스는 key와 Value 타입 매개변수를 선언한 다음 이를 멤버 속성, constructor 매개변수 타입, 메서드의 매개변수, 반환 타입으로 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Secret<Key, Value> {
key: Key;
value: Value;
constructor(key: Key, value: Value) {
this.key = key;
this.value = value;
}
getValue(key: Key): Value | undefined {
return this.key === key ? this.value : undefined;
}
}
const storage = new Secret(12345, 'luggage'); // 타입: Secret<number, string>
storage.getValue(1987); // 타입: string | undefiend
제네릭 인터페이스와 마찬가지로 클래스를 사용하는 타입 애너테이션은 해당 클래스의 제네릭 타입이 무엇인지를 타입스크립트에 나타내야 한다.
1. 명시적 제네릭 클래스 타입
- 제네릭 클래스 인스턴스화는 제네릭 함수를 호출하는 것과 동일한 타입 인수 유추 규칙을 따른다.
Secret(12345, 'luggage')
와 같이 함수 생성자에 전달된 매개변수의 타입으로부터 타입 인수를 유추할 수 있다면 타입스크립트는 유추된 타입을 사용한다.- 하지만 생성자에 전달된 인수에서 클래스 타입 인수를 유추할 수 없는 경우에는 타입 인수의 기본값은 unknown이 된다.
2. 제네릭 클래스 확장
- 제네릭 클래스는
extends
키워드 다음에 오는 기본 클래스로 사용할 수 있다. - 타입스크립트는 사용법에서 기본 클래스에 대한 타입 인수를 유추하지 않는다.
- 기본값이 없는 모든 타입 인수는 명시적 타입 애너테이션을 사용해 지정해야 한다.
3. 제네릭 인터페이스 구현
- 제네릭 클래스는 모든 필요한 매개변수를 제공함으로써 제네릭 인터페이스를 구현한다.
- 제네릭 인터페이스는 제네릭 기본 클래스를 확장하는 것과 유사하게 작동한다.
- 기본 인터페이스의 모든 타입 매개변수는 클래스에 선언되어야 한다.
4. 메서드 제네릭
- 클래스 메서드는 클래스 인스턴스와 별개로 자체 제네릭 타입을 선언할 수 있다.
- 제네릭 클래스 메서드에 대한 각각의 호출은 각 타입 매개변수에 대해 다른 타입 인수를 갖는다.
[예제]
- 제네릭 CreatePairFactory 클래스는 Key 타입을 선언하고 별도의 Value 제네릭 타입을 선언하는 createPair 메서드를 포함한다.
- 그러면 createpair의 반환 타입은
{key: Key, value: Value}
로 유추된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class CreatePairFactory<Key> {
key: Key;
constructor(key: Key) {
this.key = key;
}
createPair<Value>(value: Value) {
return { key: this.key, value };
}
}
// 타입: CreatePairFactory<string>
const factory = new CreatePairFactory('role');
// 타입: { key: string, value: number }
const numberPair = factory.createPair(10);
// 타입: { key: string, value: string }
const stringPair = factory.createPair('Sophie');
5. 정적 클래스 제네릭
- 클래스 정적 멤버는 인스턴스 멤버와 구별되고 클래스의 특정 인스턴스와 연결되어 있지 않다.
- 클래스의 정적 멤버는 클래스 인스턴스에 접근할 수 없거나 타입 정보를 지정할 수 없다.
- 따라서 정적 클래스 메서드는 자체 타입 매개변수를 선언할 수 있지만 클래스에 선언된 어떤 타입 매개변수에도 접근할 수 없다.
🌓 제네릭 타입 별칭
- 타입 인수를 사용해 제네릭을 만드는 타입스크립트의 마지막 구조체는 타입 별칭이다.
- 각 타입 별칭에는 T를 받는 Nullish 타입과 같은 임의의 수의 타입 매개변수가 주어진다.
1
type Nullish = T | null | undefined;
제네릭 타입 별칭은 일반적으로 제네릭 함수의 타입을 설명하는 함수와 함께 사용된다.
1
2
3
4
5
6
type CreatesValue<Input, Output> = (input: Input) => Output;
// 타입: (input: string) => number
let creator: CreatesValue<string, number>;
creator = (text) => text.length; // Ok
제네릭 판별된 유니언
- 판별된 유니언 사용법 중 데이터의 성공적인 결과 또는 오류로 인한 실패를 나타내는 제네릭 ‘결과’ 타입을 만들기 위해 타입 인수를 추가하는 것이다.
- 제네릭 타입과 판별된 타입을 함께 사용하면 재사용 가능한 타입을 모델링하는 훌륭한 방법을 제공할 수 있다.
🌓 제네릭 제한자
타입스크립트는 제네릭 타입 매개변수의 동작을 수정하는 구문도 제공한다.
제네릭 기본값
- 제네릭 타입이 타입 애너테이션에 사용되거나
extends
또는implements
의 기본 클래스로 사용되는 경우 각 타입 매개변수에 대한 타입 인수를 제공해야 한다. - 타입 매개변수 선언 뒤에
=
와 기본 타입을 배치해 타입 인수를 명시적으로 제공할 수 있다. - 기본값은 타입 인수가 명시적으로 선언되지 않고 유추할 수 없는 모든 후속 타입에 사용된다.
- 타입 매개변수는 동일한 선언 안의 앞선 타입 매개변수를 기본값으로 가질 수 있다.
- 각 타입 매개변수는 선언에 대한 새로운 타입을 도입하므로 해당 선언 이후의 타입 매개변수에 대한 기본값으로 이를 사용할 수 있다.
- 모든 기본 타입 매개변수는 기본 함수 매개변수처럼 선언 목록의 제일 마지막에 와야 한다.
- 기본값이 없는 제네릭 타입은 기본값이 있는 제네릭 타입 뒤에 오면 안된다.
🌓 제한된 제네릭 타입
- 기본적으로 제네릭 타입에는 클래스, 인터페이스, 원싯값, 유니언, 별칭 등 모든 타입을 제공할 수 있다.
- 그러나 일부 함수는 제한된 타입에서만 작동해야 한다.
- 타입스크립트는 타입 매개변수가 타입을 확장해야 한다고 선언할 수 있으며 별칭 타입에만 허용되는 작업이다.
- 타입 매개변수를 제한하는 구문은 매개변수 이름 뒤에
extends
키워드를 배치하고 그 뒤에 이를 제한할 타입을 배치한다.
keyof와 제한된 타입 매개변수
extends
와keyof
를 함께 사용하면 타입 매개변수를 이전 타입 매개변수의 키로 제한할 수 있다.- 또한 제네릭 타입의 키를 지정할 수 있는 유일한 방법이기도 하다.
- keyof가 없었다면 제네릭 key 매개변수를 올바르게 입력할 방법이 없었을 것이다.
1
2
3
4
5
6
7
8
9
10
function get<T>(container: T, key: keyof T) {
return container[key];
}
const roles = {
favorite: 'Fargo',
others: ['Almost Famous', 'Burn After Reading', 'Nomadland'],
};
const found = get(roles, 'favorite'); // 타입: string | string[]
제네릭 함수를 작성할 때 매개변수의 타입이 이전 매개변수 타입에 따라 달라지는 경우를 알고 있어야 하고, 이러한 경우 올바른 매개변수 타입을 위해 제한된 타입 매개변수를 자주 사용하게 된다.
🌓 Promise
- 자바스크립트의
Promise
는 네트워크 요청과 같이 요청 이후 결과를 받기까지 대기가 필요한 것을 나타낸다. - 각 Promise는 대기 중인 작업이
resolve
(성공적으로 완료됨) 또는reject
(오류 발생)하는 경우 콜백을 등록하기 위한 메서드를 제공한다. - 임의의 값 타입에 대해 유사한 작업을 나타내는 Promise의 기능은 타입스크립트의 제네릭과 자연스럽게 융합된다.
- Promise는 타입스크립트 타입 시스템에서 최종적으로 resolve된 값을 나타내는 단일 타입 매개변수를 가진 Promise 클래스로 표현된다.
1. Promise 생성
- 타입스크립트에서 Promise 생성자는 단일 매개변수를 받도록 작성된다.
- 해당 매개변수의 타입은 제네릭 Promise 클래스에 선언된 타입 매개변수에 의존한다.
축소된 형식은 다음과 같다.
[예제]
1
2
3
4
5
6
7
8
9
10
class PromiseLike<Value> {
constructor(
executor: (
resolve: (value: Value) => void,
reject: (reason: unknown) => void
) => void
) {
/* ... */
}
}
- 결과적으로 값을 resolve하려는 Promise를 만들려면 Promise의 타입 인수를 명시적으로 선언해야 한다.
- 타입스크립트는 명시적 제네릭 타입 인수가 없다면 기본적으로 매개변수 타입을 unknown으로 가정한다.
- Promise 생성자에 타입 인수를 명시적으로 제공하면 타입스크립트가 결과로서 생기는 Promise 인스턴스의 resolve된 타입을 이해할 수 있다.
1
2
3
4
5
6
7
8
9
// 타입: Promise<unknown>
const resolvesUnknown = new Promise((resolve) => {
setTimeout(() => resolve('Done!'), 1000);
});
// 타입: Promise<string>
const resolvesString = new Promise<string>((resolve) => {
setTimeout(() => resolve('Done!'), 1000);
});
2. async 함수
- 자바스크립트에서
async
키워드를 사용해 선언한 모든 함수는 Promise를 반환한다. - 자바스크립트에서 async 함수에 따라서 반환된 값이
Thenable
(.then
메서드가 있는 객체, 실제로는 거의 항상 Promise)이 아닌 경우, Promise.resolve가 호출된 것처럼 Promise로 래핑된다.
[예제]
- lengthAfterSecond는 Promise
를 직접적으로 반환하는 반면 lengthImmediately는 async 함수이고, 직접 number를 반환하기 때문에 Promise 를 반환하는 것으로 간주된다.
1
2
3
4
5
6
7
8
9
10
11
// (text: string) => Promise<number>
async function lengthAfterSecond(text: string) {
await new Promise((resolve) => setTimeout(resolve, 1000));
return text.length;
}
// (text: string) => Promise<number>
async function lengthImmediately(text: string) {
return text.length;
}
🌓 제네릭 올바르게 사용하기
타입스크립트의 모범 사례는 필요할 때만 제네릭을 사용하고, 제네릭을 사용할 때는 무엇을 위해 사용하는지 명확해야 한다.
1. 제네릭 황금률
- 함수에 타입 매개변수가 필요한지 여부를 판단할 수 있는 간단하고 빠른 방법은 타입 매개변수가 최소 두 번 이상 사용되었는지 확인하는 것이다.
- 제네릭은 타입 간의 관계를 설명하므로 제네릭 타입 매개변수가 한 곳에만 나타나면 여러 타입 간의 관계를 정의할 수 없다.
- 따라서 각 함수 타입 매개변수는 매개변수에 사용되어야 하고, 그다음 적어도 하나의 다른 매개변수 또는 함수의 반환 타입에서도 사용되어야 한다.
[예제]
logInput 함수는 input 매개변수를 선언하기 위해 Input 타입 매개변수를 정확히 한 번 사용한다.
1
2
3
function logInput<Input extends string>(input: Input) {
console.log('Hi!', input);
}
2. 제네릭 명명 규칙
- 타입스크립트를 포함한 많은 언어가 지키는 타입 매개변수에 대한 표준 명명 규칙은 기본적으로 첫 번째 타입 인수로
T
를 사용하고, 후속 타입 매개변수가 존재하면U
,V
등을 사용하는 것이다. - 타입 인수가 어떻게 사용되어야 하는지 맥락과 관련된 정보가 알려진 경우 명명 규칙은 경우에 따라 해당 용어의 첫 글자를 사용하는 것으로 확장된다.
- 예를 들어 상태 관리 라이브러리에서는 제네릭 상탤르
S
로, 데이터 구조의 키와 값은K
와V
로 나타내기도 한다.
Reference
러닝 타입스크립트 (Learning TypeScript)