안동민 개발노트 아이콘

안동민 개발노트

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)을 제공합니다.

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; 와 유사하게 작동합니다.

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; 와 유사하게 작동합니다.

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;와 같습니다.

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

특히 Exclude, Extract, NonNullable은 조건부 타입이 유니온의 각 멤버에 적용되고, 결과에서 never가 사라지는 흐름을 이해하면 훨씬 쉽게 읽힙니다.


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 키워드는 제네릭 함수를 위한 강력한 도구처럼, 타입을 위한 함수를 만드는 데 비유할 수 있습니다.


조건부 타입의 활용 예시

객체의 특정 키 타입 추출
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
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 키워드와 함께 사용하면 거의 모든 타입 조작 시나리오를 다룰 수 있을 정도로 강력해집니다.

조건부 타입은 분기 기준, 분산 여부, 추론 위치를 분리해서 보면 훨씬 읽기 쉽습니다. 다음 다이어그램은 타입 분기 설계 순서를 한 장으로 요약합니다.

처음에는 어렵게 느껴질 수 있지만, 타입스크립트 내장 유틸리티 타입 구현을 분석해 보면 유용성을 빠르게 체감할 수 있습니다.


다음 다이어그램은 조건부 타입을 조건, 분산, infer 활용 기준으로 정리한 표입니다.

조건부 타입의 마무리는 타입 안정성, 표현력, 유지보수 비용을 함께 보는 판단표로 정리합니다.

아래 다이어그램은 조건부 타입을 extends 판정, 분산, infer 추론 흐름으로 나눠 정리합니다.