📙

10장 조건부 타입

10.1 기본 사용 방법

타입스크립트에서 조건부 타입(Conditional Type)은 조건에 따라 다른 타입을 반환할 수 있는 기능을 제공합니다.
type MyType = T extends U ? X : Y
위의 예제에서 T extends U 이 조건식은 T 타입을 U 타입에 대입할 수 있는지를 검사합니다. 따라서 T 타입이 U 타입의 서브타입이면 이 조건이 참이 되어 X 타입을 반환하고, 그렇지 않으면 Y 타입을 반환합니다. 즉 아래와 같이 NumOrStr 타입을 결정할 수 있습니다.
type NumOrStr = number extends string ? number : string; // NumOrStr = string
위의 예제의 조건식은 number 타입이 string 타입의 서브타입인지 확인합니다. 하지만 number 타입은 string 타입의 서브타입이 아니므로 string 타입을 반환합니다.
다음으로 객체 타입을 사용한 조건부 타입 예제를 살펴보겠습니다.
type SuperType = { a: number; }; type SubType = { a: number; b: number; }; type Result = SubType extends SuperType ? number : string; // Result = number
위의 예제에서 SubType 타입은 SuperType 타입의 서브타입이므로 SubType extends SuperType 조건은 참이 됩니다. 따라서 Result 타입은 number 타입으로 반환됩니다.

10.1.1 제네릭 조건부 타입

제네릭 조건부 타입은 매개변수의 타입이나 특정 조건에 따라 반환 타입을 다르게 설정할 수 있어 코드의 유연성을 높일 수 있습니다. 예를 들어, 아래와 같이 제네릭 조건부 타입을 사용할 수 있습니다.
type ConversionResult<T> = T extends number ? string : number; let numberToString: ConversionResult<number>; // numberToString: string let stringToNumber: ConversionResult<string>; // stringToNumber: number
위의 예제에서 ConversionResult<T> 타입은 제네릭 조건부 타입이며, 제네릭 타입 T가 number 타입인지 검사합니다. 만약 이 조건이 만족하면 string 타입을, 그렇지 않다면 number 타입을 반환합니다. 따라서 numberToString는 string 타입이 되고, stringToNumber는 number 타입이 됩니다. 다음으로 더 복잡한 예제를 살펴보겠습니다.
interface Product { productName: string; hasDiscount: boolean; } type ProductWithDiscount = { productName: string; hasDiscount: true; discount?: number; }; type ProductWithoutDiscount = { productName: string; hasDiscount: false; }; // 제품의 할인 여부에 따라 다른 타입을 반환하는 제네릭 조건부 타입 type ProductDiscount<T> = T extends { hasDiscount: true } ? ProductWithDiscount : ProductWithoutDiscount; function getProduct<T extends Product>(product: T): ProductDiscount<T> { // 할인이 있는 경우 if (product.hasDiscount) { let newProduct = { ...product, discount: 0.1, }; return newProduct as ProductDiscount<T>; } // 할인이 없는 경우 return product as ProductDiscount<T>; } // productWithDiscount: ProductWithDiscount let productWithDiscount = getProduct({ productName: "컴퓨터", hasDiscount: true, }); // productWithoutDiscount: ProductWithoutDiscount let productWithoutDiscount = getProduct({ productName: "헤드셋", hasDiscount: false, });
위의 예제에서 ProductDiscount<T>getProduct 함수가 반환할 타입을 결정하는 제네릭 조건부 타입이며, 제네릭 타입 T의 객체가 hasDiscount 프로퍼티를 가지고, 그 값이 true 인지 검사합니다. 만약 이 조건을 만족한다면 getProduct 함수는 ProductWithDiscount 타입을 반환하고, 만족하지 않다면 ProductWithoutDiscoun 타입을 반환하게 됩니다. 따라서 getProduct 함수는 입력으로 받은 product 객체의 hasDiscount 값에 따라서, 적절한 타입을 반환할 수 있게 됩니다.
이와 같이 제네릭 조건부 타입을 활용하면 특정 조건에 따라 다른 타입을 반환하는 함수를 유연하게 구현할 수 있습니다. 또한, 여러 유형에 대해 비슷한 작업을 수행해야 할 때 오버로드된 함수를 단일 함수로 대체하는 데에도 유용하게 사용됩니다. 아래의 예시를 살펴보겠습니다.
interface UserIdData { id: number; } interface UserEmailData { email: string; } // 함수 오버로딩 function getUserData(inputData: number): UserIdData; function getUserData(inputData: string): UserEmailData; function getUserData(inputData: number | string): UserIdData | UserEmailData { if (typeof inputData === 'number') { return { id: inputData }; } else { return { email: inputData }; } } let userData1 = getUserData(123); // userData1: UserIdData let userData2 = getUserData("hayeon@test.com"); // userData2: UserEmailData
위의 예제에서 getUserData 함수는 숫자를 입력으로 받으면 UserIdData 타입을 반환하고, 문자열을 입력으로 받으면 UserEmailData 타입을 반환하는 함수 오버로딩이 있습니다. 하지만 같은 로직을 제네릭 조건부 타입을 사용하면 다음과 같이 더 간결하게 표현할 수 있습니다.
type UserData<T extends number | string> = T extends number ? UserIdData : UserEmailData; function getUserData<T extends number | string>(inputData: T): UserData<T> { if (typeof inputData === "number") { return { id: inputData } as UserData<T>; } else { return { email: inputData } as UserData<T>; } }
위의 예제에서 getUserData 함수는 제네릭 타입 T를 매개변수로 받아, UserData<T> 타입의 객체를 반환합니다. 이때 UserData 타입은 제네릭 타입 T에 따라 UserIdData 또는 UserEmailData 타입을 반환하는 조건부 타입으로, T가 number 타입이면 UserIdData 타입을, string 타입이면 UserEmailData 타입을 반환합니다.
이처럼 입력된 타입에 따라 반환 값의 타입이 다르게 사용하는 상황에서 제네릭 조건부 타입을 사용하면 함수의 입력 타입에 따라 자동으로 반환 타입이 결정되기 때문에 함수에 대한 오버로드를 따로 정의할 필요가 없어져 코드의 복잡성을 줄일 수 있습니다.
 

10.2 분산적인 조건부 타입

분산 조건부 타입(Distributive Conditional Types)이란 유니언 타입을 조건부 타입에 적용할 때 각각의 타입을 하나씩 조건부 타입에 적용시킨 후 나온 결과들을 다시 유니온 타입으로 합친 타입입니다. 분산 조건부 타입을 예시 코드를 통해 살펴보겠습니다.
type ExtractNumber<T> = T extends number ? number : never; type MyUnionType = string | number | boolean; type Result = ExtractNumber<MyUnionType>; // type Result = number
ExtractNumber 타입은 number 타입을 추출하는 타입입니다. 최종적으로 Result는 number 타입이 됩니다. ExtractNumber 타입이 어떻게 number 타입을 반환하게 되는 건지 차례차례 알아보겠습니다. 분산적 조건부 타입은 유니언 타입에 조건부 타입을 적용할 때 각 타입에 대해 개별적으로 조건부 타입을 적용한다고 했습니다. 따라서 ExtractNumberMyUnionType의 각 타입에 대해서 개별적으로 적용됩니다.
type MyType1 = ExtractNumber<string>; // never type MyType2 = ExtractNumber<number>; // number type MyType3 = ExtractNumber<boolean>; // never
이렇게 개별적으로 적용했을 때 MyType1 은 never, MyType2 은 number, MyType3 은 never 타입이 됩니다. 그리고 이 결과들을 다시 유니언 타입으로 합친 타입이 최종타입이 됩니다. 이를 코드로 작성하면 아래처럼 작성할 수 있습니다.
type Result = MyType1 | MyType2 | MyType3;
Result는 never | number | never 타입이 됩니다. 이때 유니언 타입에서 never 타입은 사라지기 때문에 최종적으로 Result는 number 타입이 됩니다.
 

10.3 infer로 타입 추론하기

infer는 inference의 약자로, 추론이라는 의미를 가지고 있습니다. 실제로 조건부 타입에서 특정 타입을 추론하는 것이 infer의 역할입니다. 주로 복잡한 로직을 간소화하기 위해 활용됩니다. infer를 사용하여 특정 타입을 추론하는 방법에 대해 자세히 알아보겠습니다.
type FunctionStr = () => string; type FunctionNum = () => number;
각각 string과 number 타입을 리턴하는 구조의 함수 타입 두 개를 선언했습니다. 이러한 특정 함수 타입에서 반환값의 타입만 추출하는 특수한 조건부 타입인 ReturnType을 만들어보겠습니다.
type ReturnType1<T> = T extends () => string ? string : never; type ReturnType2<T> = T extends () => number ? number : never; // ... type A = ReturnType1<FunctionStr>; // A : string type B = ReturnType2<FunctionNum>; // B : number // ...
현재 코드로는 반환값에 들어갈 수 있는 타입마다 ReturnType 함수 타입을 전부 만들어주어야 합니다. 하지만 infer를 활용하면 어떨까요?
type MyReturnType<T> = T extends () => infer R ? R : never; type A = MyReturnType<FunctionStr>; // A : string type B = MyReturnType<FunctionNum>; // B : number type C = MyReturnType<number>; // C : never
타입 A에서 FunctionStr을 제네릭 T에 넣으면 T() ⇒ string이 됩니다. 그럼 infer는 T를 서브 타입으로 할 수 있는 타입을 완성하기 위한 타당한 R을 추론합니다. () ⇒ string을 서브 타입으로 하는 타입은 () ⇒ string이므로 조건식을 참으로 만들기 위한 R은 string이 됩니다. 마찬가지로 B 또한 number 타입이라는 결과를 얻게 됩니다.
제네릭 타입 T에 number를 할당한 타입 C의 경우 numer를 서브 타입으로 가지는 함수 타입은 존재하지 않기 때문에 R 추론이 불가능합니다. 조건식이 거짓으로 판단될 경우 never를 리턴하도록 했으니 C는 never 타입이 됩니다.
이렇게 infer를 사용하면 각 타입마다 함수 타입을 따로 만들어서 관리하는 것보다 훨씬 간결하고 이해하기 쉬운 코드로 작성할 수 있습니다.
다음은 프로미스의 결과로 반환되는 resolve 값의 타입을 추론하는 타입을 만들어 보겠습니다.
type UnpackPromise<T> = T extends Promise<infer R> ? R : never;
프로미스 타입인 T를 받아 조건식을 참이 되도록 하는 R을 추론하여 프로미스 결과 타입을 얻고자 합니다.
type PromiseNum = UnpackPromise<Promise<number>>; // number type PromiseStr = UnpackPromise<Promise<string>>; // string
Promise<number>를 서브 타입으로 하는 Promise<infer R> 을 만들기 위하여 R은 number로 추론되고, number가 결과로 나오게 되어 PromiseNumPromise<number>의 결과 타입을 가지게 됩니다.