조건부 타입
타입스크립트의 타입 시스템은 매우 강력하여 프로그래밍 로직뿐만 아니라 타입 자체에도 조건부 로직을 적용할 수 있습니다. 이것이 바로 조건부 타입(Conditional Types) 입니다. 조건부 타입은 특정 타입이 다른 타입에 할당 가능한지 여부에 따라 다른 타입을 선택하게 하는 문법으로, 마치 자바스크립트의 조건부(삼항) 연산자 condition ? trueExpression : falseExpression
와 유사하게 동작합니다.
조건부 타입은 주로 기존의 타입을 기반으로 새로운 타입을 동적으로 생성할 때 사용되며, 제네릭과 함께 사용될 때 더욱 강력한 타입 조작이 가능해집니다.
조건부 타입의 기본 문법
조건부 타입은 다음과 같은 형태를 가집니다.
SomeType extends OtherType ? TrueType : FalseType;
SomeType
: 검사할 대상 타입extends
:SomeType
이OtherType
에 할당 가능한지 검사하는 키워드 (타입 할당 가능성 검사)OtherType
: 비교 대상 타입TrueType
:SomeType
이OtherType
에 할당 가능할 경우 선택될 타입FalseType
:SomeType
이OtherType
에 할당 가능하지 않을 경우 선택될 타입
예시를 통해 이해해봅시다.
// 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
에 할당 가능한 타입만 추출합니다. (T
와U
의 공통된 타입을 추출한다)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
에서null
과undefined
를 제외합니다.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
키워드는 제네릭 함수를 위한 강력한 도구처럼, 타입을 위한 함수를 만드는 데 비유할 수 있습니다.
조건부 타입의 활용 예시
-
객체의 특정 키 타입 추출
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
키워드와 함께 사용될 때, 거의 모든 타입 조작 시나리오를 커버할 수 있는 강력한 도구가 됩니다. 처음에는 이해하기 어려울 수 있지만, 타입스크립트의 내장 유틸리티 타입들이 어떻게 구현되었는지 살펴보면 그 유용성을 깨달을 수 있습니다.