# 고급 TypeScript: 조건부 타입 완벽 가이드
Table of Contents
조건부 타입이란 무엇인가요?
조건부 타입(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> // true
type B = IsString<number> // false
type C = IsString<'hello'> // true (리터럴 타입은 string에 할당 가능)
실전 예제: 함수 반환 타입 판별하기
type IsFunction<T> = T extends (...args: any[]) => any ? true : false
type FuncTest1 = IsFunction<() => void> // true
type FuncTest2 = IsFunction<string> // false
type 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> // any
2. 함수 매개변수 타입 추출하기 (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>
// 결과: string
3. 배열 요소 타입 추출하기
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> // number
4. 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 | undefined
type 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 }> // true
type Test2 = IsJsonSerializable<{ func: () => void }> // false
type 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 키워드와 결합하면 런타임 로직만큼이나 유연한 타입 로직을 작성할 수 있습니다. 처음에는 낯설게 느껴질 수 있지만, 익숙해지면 라이브러리 개발이나 복잡한 비즈니스 로직의 타입을 완벽하게 모델링하는 데 없어서는 안 될 무기가 될 것입니다.