icon
5장 : 고급 타입

조건부 타입


타입스크립트의 타입 시스템은 매우 강력하여 프로그래밍 로직뿐만 아니라 타입 자체에도 조건부 로직을 적용할 수 있습니다. 이것이 바로 조건부 타입(Conditional Types) 입니다. 조건부 타입은 특정 타입이 다른 타입에 할당 가능한지 여부에 따라 다른 타입을 선택하게 하는 문법으로, 마치 자바스크립트의 조건부(삼항) 연산자 condition ? trueExpression : falseExpression와 유사하게 동작합니다.

조건부 타입은 주로 기존의 타입을 기반으로 새로운 타입을 동적으로 생성할 때 사용되며, 제네릭과 함께 사용될 때 더욱 강력한 타입 조작이 가능해집니다.


조건부 타입의 기본 문법

조건부 타입은 다음과 같은 형태를 가집니다.

SomeType extends OtherType ? TrueType : FalseType;
  • SomeType: 검사할 대상 타입
  • extends: SomeTypeOtherType에 할당 가능한지 검사하는 키워드 (타입 할당 가능성 검사)
  • OtherType: 비교 대상 타입
  • TrueType: SomeTypeOtherType에 할당 가능할 경우 선택될 타입
  • FalseType: SomeTypeOtherType에 할당 가능하지 않을 경우 선택될 타입

예시를 통해 이해해봅시다.

// IsString 타입은 T가 string에 할당 가능하면 'yes', 아니면 'no'를 반환합니다.
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // type A = "yes"
type B = IsString<number>;  // type B = "no"
type C = IsString<any>;     // type C = "yes" (any는 모든 타입에 할당 가능)
type D = IsString<unknown>; // type D = "no" (unknown은 string에 할당 불가)
type E = IsString<"hello">; // type E = "yes" ("hello" 리터럴 타입은 string에 할당 가능)

console.log(`A의 타입: ${typeof (null as any as A)}`); // A는 "yes" 타입 리터럴이므로 실제 값은 "yes"
console.log(`B의 타입: ${typeof (null as any as B)}`); // B는 "no" 타입 리터럴이므로 실제 값은 "no"

이처럼 T extends string 조건이 true이면 IsString<T>"yes" 타입이 되고, false이면 "no" 타입이 됩니다.


분산 조건부 타입

제네릭 타입 변수가 유니온 타입일 때, 조건부 타입은 유니온의 각 멤버에 개별적으로 적용됩니다. 이를 분산 조건부 타입(Distributive Conditional Types) 이라고 부릅니다. 이 특성은 특정 유니온 타입에서 원하는 타입만 추출하거나 제외할 때 매우 유용합니다.

// ToArray는 T가 string이면 T[]를, 아니면 T를 반환합니다.
type ToArray<T> = T extends string ? T[] : T;

type StrArray = ToArray<string>;       // type StrArray = string[]
type Num = ToArray<number>;            // type Num = number
type Bool = ToArray<boolean>;          // type Bool = boolean

// T가 유니온 타입일 때 분산됩니다.
type StringOrNumber = string | number;
type MixedType = ToArray<StringOrNumber>; // type MixedType = string[] | number
// 이는 ToArray<string> | ToArray<number> 와 동일하게 동작합니다.
// 즉, string[] | number가 됩니다.

type StringOrBoolean = string | boolean;
type AnotherMixedType = ToArray<StringOrBoolean>; // type AnotherMixedType = string[] | boolean

ToArray<StringOrNumber>string에는 string[]을, number에는 number를 적용하여 최종적으로 string[] | number가 됩니다.

이 분산 조건부 타입의 특성을 활용하여 타입스크립트의 내장 유틸리티 타입들이 구현되어 있습니다.


조건부 타입을 활용한 유틸리티 타입

타입스크립트는 조건부 타입을 사용하여 매우 유용한 내장 유틸리티 타입들 (Built-in Utility Types)을 제공합니다.

  1. Exclude<T, U>: T에서 U에 할당 가능한 타입을 제외합니다. (T에서 U에 포함된 타입을 뺀다)

    type NonNullableStringOrNumber = Exclude<string | number | null | undefined, null | undefined>;
    // type NonNullableStringOrNumber = string | number
    // string, number는 null | undefined에 할당 불가하므로 그대로 유지
    // null, undefined는 null | undefined에 할당 가능하므로 제외

    Exclude<T, U>의 내부 구현은 대략 type Exclude<T, U> = T extends U ? never : T; 와 유사하게 작동합니다.

  2. Extract<T, U>: T에서 U에 할당 가능한 타입만 추출합니다. (TU의 공통된 타입을 추출한다)

    type OnlyStrings = Extract<string | number | boolean, string>;
    // type OnlyStrings = string
    // string은 string에 할당 가능하므로 추출
    // number, boolean은 string에 할당 불가하므로 제외

    Extract<T, U>의 내부 구현은 대략 type Extract<T, U> = T extends U ? T : never; 와 유사하게 작동합니다.

  3. NonNullable<T>: T에서 nullundefined를 제외합니다.

    type PureData = NonNullable<string | number | null | undefined>;
    // type PureData = string | number

    NonNullable<T>의 내부 구현은 type NonNullable<T> = T extends null | undefined ? never : T;와 같습니다.

이러한 유틸리티 타입들은 복잡한 유니온 타입을 정교하게 다루는 데 필수적입니다.


infer 키워드와 타입 추론

조건부 타입 내에서 infer 키워드를 사용하면, extends 절에서 특정 위치의 타입을 추론(Infer) 하여 새로운 타입 변수로 사용할 수 있습니다. 이는 복잡한 타입의 일부를 추출하여 재구성할 때 매우 강력합니다.

// ReturnType<T>는 함수 타입 T의 반환 타입을 추론합니다.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type FuncA = () => string;
type FuncB = (num: number, str: string) => boolean;
type FuncC = () => void;

type ReturnOfA = MyReturnType<FuncA>; // type ReturnOfA = string
type ReturnOfB = MyReturnType<FuncB>; // type ReturnOfB = boolean
type ReturnOfC = MyReturnType<FuncC>; // type ReturnOfC = void
type ReturnOfNonFunc = MyReturnType<number>; // type ReturnOfNonFunc = any

// Parameters<T>는 함수 타입 T의 매개변수 타입을 튜플로 추론합니다.
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

type ParamsOfA = MyParameters<FuncA>; // type ParamsOfA = []
type ParamsOfB = MyParameters<FuncB>; // type ParamsOfB = [num: number, str: string]
type ParamsOfC = MyParameters<FuncC>; // type ParamsOfC = []
type ParamsOfNonFunc = MyParameters<number>; // type ParamsOfNonFunc = never

T extends (...args: any[]) => infer R ? R : any 구문을 분석해봅시다.

  • T가 함수 타입인지 검사합니다: T extends (...args: any[]) => any.
  • 만약 T가 함수 타입이라면, 해당 함수의 반환 타입을 infer R을 통해 R이라는 새로운 타입 변수로 추론합니다.
  • 그리고 이 추론된 R 타입을 최종 결과로 반환합니다.
  • 함수 타입이 아니라면 any를 반환합니다.

infer 키워드는 제네릭 함수를 위한 강력한 도구처럼, 타입을 위한 함수를 만드는 데 비유할 수 있습니다.


조건부 타입의 활용 예시

  1. 객체의 특정 키 타입 추출

    interface UserProfile {
      id: number;
      name: string;
      email: string;
      age: number;
    }
    
    // KeyType은 UserProfile의 키 K에 해당하는 값의 타입을 반환합니다.
    type GetPropertyValue<T, K extends keyof T> = T[K];
    
    type UserIdType = GetPropertyValue<UserProfile, 'id'>;   // type UserIdType = number
    type UserNameType = GetPropertyValue<UserProfile, 'name'>; // type UserNameType = string
  2. Promise의 값 타입 추출

    // UnwrapPromise는 Promise<T>에서 T를 추출합니다.
    type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
    
    type ValueFromPromise = UnwrapPromise<Promise<string>>; // type ValueFromPromise = string
    type DirectValue = UnwrapPromise<number>;             // type DirectValue = number

조건부 타입은 타입스크립트의 타입 시스템을 매우 유연하고 표현력 있게 만들어주는 고급 기능입니다. 이를 통해 기존 타입을 기반으로 복잡한 로직을 가진 새로운 타입을 동적으로 생성할 수 있습니다. 특히 제네릭, 분산 조건부 타입, 그리고 infer 키워드와 함께 사용될 때, 거의 모든 타입 조작 시나리오를 커버할 수 있는 강력한 도구가 됩니다. 처음에는 이해하기 어려울 수 있지만, 타입스크립트의 내장 유틸리티 타입들이 어떻게 구현되었는지 살펴보면 그 유용성을 깨달을 수 있습니다.