조건부 타입이란 무엇인가요?
조건부 타입(Conditional Types)은 TypeScript 2.8에서 도입된 강력한 기능으로, 입력된 타입에 따라 출력 타입을 동적으로 결정할 수 있게 해줍니다. JavaScript의 삼항 연산자와 매우 유사한 문법을 사용합니다.
T extends U ? X : Y이 구문은 “만약 타입 T가 타입 U에 할당 가능하다면 X 타입을 반환하고, 그렇지 않다면 Y 타입을 반환한다”는 의미입니다.
기본 문법 익히기
가장 기초적인 예제부터 살펴보겠습니다.
type IsString<T> = T extends string ? true : false
type A = IsString<string> // truetype B = IsString<number> // falsetype C = IsString<'hello'> // true (리터럴 타입은 string에 할당 가능)실전 예제: 함수 반환 타입 판별하기
type IsFunction<T> = T extends (...args: any[]) => any ? true : false
type FuncTest1 = IsFunction<() => void> // truetype FuncTest2 = IsFunction<string> // falsetype FuncTest3 = IsFunction<(x: number) => string> // true분산 조건부 타입 (Distributive Conditional Types)
제네릭 타입이 유니온(Union) 타입으로 주어졌을 때, 조건부 타입은 각 구성 요소로 분리되어(분산되어) 연산됩니다. 이러한 동작 방식을 정확히 이해하는 것이 매우 중요합니다.
type ToArray<T> = T extends any ? T[] : never
type StringOrNumberArray = ToArray<string | number>// 결과: string[] | number[]// 설명: (string extends any ? string[] : never) | (number extends any ? number[] : never)// 주의: (string | number)[] 가 아닙니다!
// 분산을 막고 싶다면? 대괄호로 감싸세요!type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never
type UnionArray = ToArrayNonDistributive<string | number>// 결과: (string | number)[]infer 키워드: 타입 추론의 마법
infer 키워드는 조건부 타입 내에서 특정 부분의 타입을 변수처럼 추론하여 가져올 때 사용합니다. 고급 타입 유틸리티를 만들 때 필수적인 기능입니다.
1. 함수의 반환 타입 추출하기 (ReturnType 직접 구현해보기)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function getUser() { return { id: 1, name: 'Alice', email: 'alice@example.com' }}
type UserType = MyReturnType<typeof getUser>// 결과: { id: number; name: string; email: string; }
// 비동기 함수 예제async function fetchData() { const response = await fetch('/api/data') return response.json()}
type FetchedData = MyReturnType<typeof fetchData>// 결과: Promise<any>
// Promise 내부의 타입까지 꺼내고 싶다면?type Awaited<T> = T extends Promise<infer U> ? U : T
type ActualData = Awaited<FetchedData> // any2. 함수 매개변수 타입 추출하기 (Parameters)
type MyParameters<T> = T extends (...args: infer P) => any ? P : never
function createUser(name: string, age: number, email: string) { return { name, age, email }}
type CreateUserParams = MyParameters<typeof createUser>// 결과: [name: string, age: number, email: string] (튜플 타입)
// 첫 번째 매개변수만 쏙 뽑아내기type FirstParameter<T> = T extends (first: infer F,...args: any[]) => any ? F : never
type FirstParam = FirstParameter<typeof createUser>// 결과: string3. 배열 요소 타입 추출하기
type ArrayElement<T> = T extends (infer E)[] ? E : never
type NumberArray = number[]type Element = ArrayElement<NumberArray> // number
type MixedArray = (string | number)[]type MixedElement = ArrayElement<MixedArray> // string | number
// 다차원 배열도 재귀적으로 처리하기type DeepArrayElement<T> = T extends (infer E)[] ? E extends any[] ? DeepArrayElement<E> : E : T
type Nested = number[][][]type DeepElement = DeepArrayElement<Nested> // number4. Promise 타입 벗겨내기 (Unwrap)
type Unpromise<T> = T extends Promise<infer U> ? U : T
type AsyncNumber = Promise<number>type SyncNumber = Unpromise<AsyncNumber> // number
type SyncString = Unpromise<string> // string (Promise가 아니면 그대로 반환)
// 중첩된 Promise도 한 번에 벗겨내기 (재귀)type DeepUnpromise<T> = T extends Promise<infer U> ? DeepUnpromise<U> : T
type NestedPromise = Promise<Promise<Promise<string>>>type Unwrapped = DeepUnpromise<NestedPromise> // string실전 활용 사례 BEST 5
1. Nullable 타입 제거하기
type NonNullableType<T> = T extends null | undefined ? never : T
type MaybeString = string | null | undefinedtype DefinitelyString = NonNullableType<MaybeString> // string
// 객체의 모든 속성에서 null/undefined 제거type RemoveNullableProps<T> = { [K in keyof T]: NonNullableType<T[K]>}
interface User { id: number name: string email: string | null phone: string | undefined}
type CleanUser = RemoveNullableProps<User>// {// id: number// name: string// email: string// phone: string// }2. 읽기 전용(Readonly) 속성만 골라내기
type ReadonlyKeys<T> = { [K in keyof T]: (<F>() => F extends { [Q in K]: T[K] } ? 1 : 2) extends (<F>() => F extends { -readonly [Q in K]: T[K] } ? 1 : 2) ? never : K}[keyof T]
interface Example { readonly id: number name: string readonly createdAt: Date updatedAt: Date}
type ReadonlyProps = ReadonlyKeys<Example>// 결과: 'id' | 'createdAt'3. 특정 타입(예: 함수)의 속성만 추출하기
// 값이 함수인 키만 추출type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never}[keyof T]
// 값이 함수가 아닌 키만 추출type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K}[keyof T]
interface User { id: number name: string save(): Promise<void> delete(): Promise<void> validate(field: string): boolean}
type UserMethods = FunctionPropertyNames<User>// 결과: 'save' | 'delete' | 'validate'
type UserData = NonFunctionPropertyNames<User>// 결과: 'id' | 'name'
// 유틸리티 타입으로 만들기: 메서드만 있는 객체 타입 생성type Methods<T> = Pick<T, FunctionPropertyNames<T>>
type UserMethodsOnly = Methods<User>// {// save(): Promise<void>// delete(): Promise<void>// validate(field: string): boolean// }4. API 응답 타입 자동 변환
type ApiResponse<T> = { data: T status: number message: string}
type UnwrapResponse<T> = T extends ApiResponse<infer U> ? U : T
type UserResponse = ApiResponse<{ id: number; name: string }>type UserData = UnwrapResponse<UserResponse>// 결과: { id: number; name: string }
// 여러 API 응답 타입을 한 번에 정리type UnwrapAll<T> = { [K in keyof T]: UnwrapResponse<T[K]>}
interface Responses { user: ApiResponse<{ id: number; name: string }> posts: ApiResponse<{ id: number; title: string }[]> comments: ApiResponse<{ id: number; text: string }[]>}
type AllData = UnwrapAll<Responses>// {// user: { id: number; name: string }// posts: { id: number; title: string }[]// comments: { id: number; text: string }[]// }5. 중첩 객체 평탄화 (Flatten)
type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T
type NestedArray = number[][][]type Flat = Flatten<NestedArray> // number
// 객체 평탄화 (재귀적)type FlattenObject<T> = T extends object ? T extends infer O ? { [K in keyof O]: FlattenObject<O[K]> } : never : T
type Nested = { user: { profile: { name: string age: number } }}
type Flattened = FlattenObject<Nested>// { user: { profile: { name: string; age: number; } } }고급 패턴: 한 단계 더 나아가기
재귀적 조건부 타입 (JSON 직렬화 가능 여부 체크)
type IsJsonSerializable<T> = T extends string | number | boolean | null ? true : T extends object ? T extends Function ? false : { [K in keyof T]: IsJsonSerializable<T[K]> }[keyof T] extends false ? false : true : false
type Test1 = IsJsonSerializable<{ name: string; age: number }> // truetype Test2 = IsJsonSerializable<{ func: () => void }> // falsetype Test3 = IsJsonSerializable<{ data: { nested: string } }> // true템플릿 리터럴 타입과 조합하기
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // 'onClick'type MouseEvent = EventName<'mouseOver'> // 'onMouseOver'
// 자동 이벤트 핸들러 타입 생성type EventHandler<T extends string> = { [K in EventName<T>]: (event: Event) => void}
type ButtonEvents = EventHandler<'click' | 'doubleClick' | 'mouseOver'>// {// onClick: (event: Event) => void// onDoubleClick: (event: Event) => void// onMouseOver: (event: Event) => void// }성능과 주의사항
:::caution 조건부 타입 사용 시 주의할 점
- 과도한 재귀는 피하세요: TypeScript 컴파일러는 재귀 깊이 제한(일반적으로 50 레벨)이 있어, 너무 깊은 재귀는 컴파일 에러를 유발할 수 있습니다.
- 컴파일 시간: 복잡한 조건부 타입이 많아지면 프로젝트의 컴파일 시간이 눈에 띄게 늘어날 수 있습니다.
- 가독성: “이게 무슨 타입이지?” 싶을 정도로 복잡해지면, 반드시 type 별칭(alias)으로 의미 단위로 분리하고 주석을 달아주세요. :::
모범 사례 (Best Practices)
:::tip 효과적인 사용법
- 직관적인 네이밍: T, U 대신 TResponse, TInput처럼 의미를 알 수 있는 이름을 사용하세요.
- 작게 쪼개기: 거대한 조건부 타입 하나보다, 작은 유틸리티 타입 여러 개를 조합하는 것이 이해하기 쉽습니다.
- 테스트 필수: 타입도 테스트가 필요합니다. tsd 같은 도구로 복잡한 타입이 의도대로 동작하는지 확인하세요.
- 문서화: 동료 개발자를 위해 복잡한 유틸리티 타입에는 반드시 설명(JSDoc)을 추가하세요. :::
결론
조건부 타입은 TypeScript의 타입 시스템을 정적인 검사 도구에서 프로그래밍 가능한 도구로 진화시켰습니다. infer 키워드와 결합하면 런타임 로직만큼이나 유연한 타입 로직을 작성할 수 있습니다. 처음에는 낯설게 느껴질 수 있지만, 익숙해지면 라이브러리 개발이나 복잡한 비즈니스 로직의 타입을 완벽하게 모델링하는 데 없어서는 안 될 무기가 될 것입니다.