조건부 타입과 제네릭 조건부 타입은 타입스크립트에서 강력한 기능으로, 조건에 따라 다른 타입을 적용할 수 있게 해줍니다. 이 기능을 이해하면 타입의 유연성을 활용하여 코드의 안정성과 재사용성을 높일 수 있습니다. 조건부 타입을 실용적으로 어떻게 사용할 수 있는지 하나씩 살펴보겠습니다.
1. 기본 조건부 타입
조건부 타입은 **extends**와 삼항 연산자를 사용하여 조건에 따라 타입을 결정하는 방식입니다.
type A = number extends string ? number : string;
위 코드에서 A 타입은 number extends string 조건이 거짓이기 때문에 string 타입이 됩니다. 조건이 참이라면 number 타입이 되었겠지만, **number**는 **string**의 서브타입이 아니므로 결과는 **string**이 됩니다.
객체 타입 예시
조건부 타입은 객체 타입에서도 사용할 수 있습니다.
type ObjA = { a: number };
type ObjB = { a: number; b: number };
type B = ObjB extends ObjA ? number : string;
여기서 **ObjB**는 **ObjA**의 서브타입입니다. 따라서 조건이 참이 되어 **B**의 타입은 **number**가 됩니다. 이는 객체 타입이 조건부 타입에서 서브타입으로 사용되는 방법을 보여줍니다.
2. 제네릭 조건부 타입
조건부 타입은 제네릭과 함께 사용할 때 더 큰 효과를 발휘합니다. 제네릭 타입 변수에 따라 다른 타입을 반환하도록 정의할 수 있습니다.
type StringNumberSwitch<T> = T extends number ? string : number;
let varA: StringNumberSwitch<number>;// stringlet varB: StringNumberSwitch<string>;// number
**varA**는 타입 변수 **T**에 number 타입을 할당하기 때문에 조건이 참으로 평가되어 string 타입이 됩니다. 반면 **varB**는 **string**을 할당하므로 조건이 거짓이 되어 number 타입이 됩니다.
3. 실용 예제: 인자의 타입에 따라 반환 타입을 결정하는 함수
다음과 같은 removeSpaces 함수가 있다고 가정해 봅시다. 이 함수는 문자열에서 공백을 제거해 반환합니다.
function removeSpaces(text: string) {
return text.replaceAll(" ", "");
}
let result = removeSpaces("hi im winterlood");
이 함수에 **undefined**나 null 타입의 값도 전달될 수 있다면, 인자 타입을 다음과 같이 변경해야 합니다.
function removeSpaces(text: string | undefined | null) {
return text.replaceAll(" ", "");// ❌ 오류 발생
}
위 코드에서는 **text**가 항상 문자열이 아님을 타입스크립트가 인식하여 오류를 발생시킵니다. 이 문제를 해결하기 위해 타입 좁히기를 사용하여 **text**의 타입이 **string**일 때만 **replaceAll**을 호출할 수 있게 할 수 있습니다.
function removeSpaces(text: string | undefined | null) {
if (typeof text === "string") {
return text.replaceAll(" ", "");
} else {
return undefined;
}
}
이제 오류는 해결되었지만, **result**의 타입이 **string | undefined**로 추론됩니다. 이를 개선하여 인자로 string을 전달했을 때 반환 타입도 string이 되도록 하려면 조건부 타입을 사용할 수 있습니다.
4. 조건부 타입을 사용한 함수의 반환 타입 제어
조건부 타입을 활용하여 인자 타입에 따라 반환 타입이 달라지도록 정의해 보겠습니다.
function removeSpaces<T>(text: T): T extends string ? string : undefined {
if (typeof text === "string") {
return text.replaceAll(" ", "");// ❌ 타입 오류
} else {
return undefined;// ❌ 타입 오류
}
}
이 코드에서, 타입스크립트는 T extends string ? string : undefined 조건부 타입을 사용하여 반환 타입을 설정했습니다. 이제 string 타입을 전달하면 **string**이 반환되고, **undefined**나 null 타입을 전달하면 **undefined**가 반환되도록 의도했지만, 함수 내부에서 여전히 타입 오류가 발생합니다. 이는 조건부 타입의 결과를 함수 내부에서 알 수 없기 때문입니다.
5. 타입 단언의 한계와 함수 오버로딩 사용
타입 오류를 피하기 위해 다음과 같이 **any**로 타입을 단언할 수 있지만, 이는 올바른 해결책이 아닙니다.
function removeSpaces<T>(text: T): T extends string ? string : undefined {
if (typeof text === "string") {
return text.replaceAll(" ", "") as any;
} else {
return undefined as any;
}
}
타입 단언을 사용하면 타입 검사를 무시하므로 **string**이 아닌 값을 반환해도 오류가 발생하지 않습니다. 이는 바람직하지 않으므로, 함수 오버로딩을 사용하는 것이 좋습니다.
함수 오버로딩을 이용한 해결
함수 오버로딩을 통해 타입을 더욱 정확히 지정할 수 있습니다.
function removeSpaces<T extends string | undefined | null>(text: T): T extends string ? string : undefined;
function removeSpaces(text: any) {
if (typeof text === "string") {
return text.replaceAll(" ", "");
} else {
return undefined;
}
}
let result = removeSpaces("hi im winterlood");// stringlet result2 = removeSpaces(undefined);// undefined
이제 result 변수는 string 타입으로, **result2**는 undefined 타입으로 정확히 추론됩니다. 오버로드 시그니처를 통해 조건부 타입이 올바르게 동작하도록 했기 때문에, any 단언 없이도 오류 없이 구현할 수 있습니다.
요약 정리
- 조건부 타입: **extends**와 삼항 연산자를 사용하여 조건에 따라 타입을 결정하는 타입스크립트의 기능입니다.
- 제네릭 조건부 타입: 제네릭과 함께 사용하여 타입의 유연성을 극대화할 수 있으며, 입력 타입에 따라 반환 타입을 다르게 설정할 수 있습니다.
- 실용 예제: removeSpaces 함수에서 조건부 타입과 함수 오버로딩을 함께 사용하여, 인자의 타입에 따라 반환 타입을 정확하게 설정하는 예제를 구현했습니다.
조건부 타입과 함수 오버로딩을 함께 사용하면 타입 안정성을 높이면서도 유연한 함수 정의가 가능합니다.
분산적인 조건부 타입
1type StringNumberSwitch<T> = T extends number ? string : number;
2
3let a: StringNumberSwitch<number>;
4
5let b: StringNumberSwitch<string>;
변수 a의 타입은 조건식이 참이되어 string으로 정의되고 변수 b의 타입은 조건식이 거짓이 되어 number 타입으로 정의됩니다.
그럼 이번에는 타입 변수에 Union 타입을 할당해 보겠습니다.
1type StringNumberSwitch<T> = T extends number ? string : number;
2
3(...)
4
5let c: StringNumberSwitch<number | string>;
6// string | number
지금까지 배운 조건부 타입 문법에 따라 변수 c의 타입은 number | string은 number의 서브타입이 아니므로 조건식이 거짓이 되어 number가 될거라고 예상할 수 있습니다.
그러나 변수 c는 string | number 타입으로 정의됩니다. 왜 이렇게 되는 걸까요?
그 이유는 조건부 타입의 타입 변수에 Union 타입을 할당하면 분산적인 조건부 타입으로 조건부 타입이 업그레이드 되기 때문입니다.
분산적인 조건부 타입은 다음과 같이 동작합니다.
타입 변수에 할당한 Union 타입 내부의 모든 타입이 분리됩니다. 따라서 StringNuberSwitch<number | string> 타입은 다음과 같이 분산됩니다.
- StringNumberSwitch<number>
- StringNumberSwitch<string>
그리고 다음으로 분산된 각 타입의 결과를 모아 다시 Union 타입으로 묶습니다.
- 결과 : number | string
Exclude 조건부 타입 구현하기
분산적인 조건부 타입의 특징을 이용하면 매우 다양한 타입을 정의할 수 있습니다.
예를 들어 Union 타입으로부터 특정 타입만 제거하는 Exclude(제외하다) 타입을 다음과 같이 정의할 수 있습니다.
1type Exclude<T, U> = T extends U ? never : T;
2
3type A = Exclude<number | string | boolean, string>;
위 코드는 다음의 흐름으로 동작합니다.
- Union 타입이 분리된다.
- Exclude<number, string>
- Exclude<string, string>
- Exclude<boolean, string>
- 각 분리된 타입을 모두 계산한다.
- T = number, U = string 일 때 number extends string 은 거짓이므로 결과는 number
- T = string, U = string 일 때 string extends string 은 참이므로 결과는 never
- T = boolean, U = string 일 때 boolean extends string 은 거짓이므로 결과는 boolean
- 계산된 타입들을 모두 Union으로 묶는다
- 결과 : number | never | boolean
계산 결과 타입 A는 number | never | boolean 타입으로 정의됩니다. 그런데 여기서 공집합을 의미하는 never 타입은 Union으로 묶일 경우 사라집니다. 그 이유는 공집합과 어떤 집합의 합집합은 그냥 원본 집합이 되기 때문입니다.
따라서 최종적으로 타입 A는 number | boolean 타입이 됩니다.
Infer
**infer**는 TypeScript의 조건부 타입 내에서 타입을 추론할 수 있게 해주는 기능입니다. 조건부 타입과 제네릭을 활용하여 특정 타입의 내부에서 원하는 타입만 추출하거나 가공할 수 있습니다. 특히, 함수의 반환 타입이나 Promise의 결과 타입을 추출할 때 매우 유용합니다. 아래에서 **infer**의 개념과 사용법을 단계적으로 설명하겠습니다.
1. 기본 개념: 조건부 타입과 infer
**infer**는 조건부 타입에서 특정 타입을 추론하도록 도와줍니다. 이를 통해 주어진 타입에서 특정 부분을 추출하거나 조건에 맞는 타입을 자동으로 결정할 수 있습니다.
- 조건부 타입의 기본 구조는 A extends B ? C : D 형태입니다.
- **infer**는 조건부 타입 내에서 **infer R**과 같은 형태로 사용되며, 여기서 **R**은 타입스크립트가 조건을 만족하는 경우 추론해야 하는 타입입니다.
2. 예제 1: 함수의 반환 타입 추출하기
다음은 **infer**를 사용하여 함수 타입에서 반환값의 타입만 추출하는 조건부 타입입니다.
type ReturnType<T> = T extends () => infer R ? R : never;
동작 원리
- 이 타입은 T가 함수 타입인지 확인한 후, 함수라면 그 반환 타입(R)을 추출합니다.
- infer R 부분에서 **R**은 조건식이 성립할 때 추론되는 타입입니다.
- 만약 **T**가 함수가 아니라면, 조건식을 만족하지 않으므로 **never**를 반환합니다.
예제 코드
type FuncA = () => string;
type FuncB = () => number;
type A = ReturnType<FuncA>;// stringtype B = ReturnType<FuncB>;// number
여기서 **ReturnType<FuncA>**는 FuncA 타입을 입력으로 받아 반환 타입을 추출합니다.
- **FuncA**는 **() => string**이므로, **T extends () => infer R ? R : never**에서 **R**은 **string**으로 추론됩니다.
- 결과적으로 A 타입은 **string**이 됩니다.
추론 불가능한 경우
만약 함수 타입이 아닌 값을 **ReturnType**에 전달하면 조건이 성립하지 않으므로 **never**가 반환됩니다.
type C = ReturnType<number>;// never
**number**는 함수 타입이 아니기 때문에 조건을 만족하는 **R**을 추론할 수 없어 **never**로 평가됩니다.
3. 예제 2: Promise의 결과 타입 추출하기
**infer**를 사용하면 Promise 내부의 결과 타입을 쉽게 추출할 수 있습니다.
type PromiseUnpack<T> = T extends Promise<infer R> ? R : never;
동작 원리
- 이 타입은 **T**가 Promise 타입인지 확인하고, Promise라면 내부에서 해제된(resolve된) 타입을 추출합니다.
- **T extends Promise<infer R>**에서 **infer R**을 통해 **R**이 **Promise**의 결과 타입으로 추론됩니다.
- 만약 **T**가 Promise 타입이 아니라면 조건식이 거짓으로 평가되어 **never**를 반환합니다.
예제 코드
type PromiseA = PromiseUnpack<Promise<number>>;// numbertype PromiseB = PromiseUnpack<Promise<string>>;// string
여기서 **PromiseUnpack<Promise<number>>**의 흐름을 보면 다음과 같습니다.
- **Promise<number>**가 **T**로 전달됩니다.
- **T extends Promise<infer R>**에서 **R**은 **number**로 추론됩니다.
- 따라서 **PromiseUnpack<Promise<number>>**의 결과는 **number**가 됩니다.
만약 **T**가 Promise 타입이 아니라면 **never**가 됩니다.
type NonPromise = PromiseUnpack<number>;// never
정리
infer 키워드는 조건부 타입 내에서 특정 타입을 추론하여 조건에 맞는 타입을 반환할 때 유용합니다.
- 함수 반환 타입 추출: **T extends () => infer R ? R : never**를 통해 함수 타입에서 반환 타입을 추출할 수 있습니다.
- Promise 내부 타입 추출: **T extends Promise<infer R> ? R : never**로 Promise 내부의 해제된 타입을 추출할 수 있습니다.
- 추론 불가 시 never 반환: 조건이 성립하지 않으면 never 타입이 반환됩니다.
이러한 infer 사용법은 복잡한 타입 추론이 필요한 경우, 특히 제네릭 타입을 더욱 유연하고 강력하게 정의하는 데 유용합니다.
'TypeScript' 카테고리의 다른 글
TypeScript 유틸리티 타입 (1) | 2024.11.03 |
---|---|
TypeScript 타입조작하기 (0) | 2024.11.02 |
TypeScript 제네릭2 (0) | 2024.10.29 |
TypeScript 제네릭1 (1) | 2024.10.29 |
TypeScript 인터페이스로 구현하는 클래스 (0) | 2024.10.29 |