본문으로 건너뛰기
TypeScript 조건부 타입 완벽 가이드

# 고급 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 조건부 타입 사용 시 주의할 점

  1. 과도한 재귀는 피하세요: TypeScript 컴파일러는 재귀 깊이 제한(일반적으로 50 레벨)이 있어, 너무 깊은 재귀는 컴파일 에러를 유발할 수 있습니다.
  2. 컴파일 시간: 복잡한 조건부 타입이 많아지면 프로젝트의 컴파일 시간이 눈에 띄게 늘어날 수 있습니다.
  3. 가독성: “이게 무슨 타입이지?” 싶을 정도로 복잡해지면, 반드시 type 별칭(alias)으로 의미 단위로 분리하고 주석을 달아주세요. :::

모범 사례 (Best Practices)

:::tip 효과적인 사용법

  1. 직관적인 네이밍: T, U 대신 TResponse, TInput처럼 의미를 알 수 있는 이름을 사용하세요.
  2. 작게 쪼개기: 거대한 조건부 타입 하나보다, 작은 유틸리티 타입 여러 개를 조합하는 것이 이해하기 쉽습니다.
  3. 테스트 필수: 타입도 테스트가 필요합니다. tsd 같은 도구로 복잡한 타입이 의도대로 동작하는지 확인하세요.
  4. 문서화: 동료 개발자를 위해 복잡한 유틸리티 타입에는 반드시 설명(JSDoc)을 추가하세요. :::

결론

조건부 타입은 TypeScript의 타입 시스템을 정적인 검사 도구에서 프로그래밍 가능한 도구로 진화시켰습니다. infer 키워드와 결합하면 런타임 로직만큼이나 유연한 타입 로직을 작성할 수 있습니다. 처음에는 낯설게 느껴질 수 있지만, 익숙해지면 라이브러리 개발이나 복잡한 비즈니스 로직의 타입을 완벽하게 모델링하는 데 없어서는 안 될 무기가 될 것입니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트