TypeScript 조건부 타입의 원리, 분배적 조건부 타입, infer 키워드와의 조합, 그리고 실전 활용 패턴을 깊이 있게 다룹니다.
4장에서 using 선언을 통한 리소스 관리를 배웠습니다. 이제부터 4개 장에 걸쳐 TypeScript 타입 시스템의 핵심 구성요소를 심층 분석합니다. 이 장에서는 그 첫 번째로 조건부 타입(Conditional Types) 을 다룹니다. 조건부 타입은 타입 수준에서 조건 분기를 수행하는 메커니즘으로, TypeScript 타입 시스템의 표현력을 비약적으로 확장합니다.
조건부 타입은 삼항 연산자와 유사한 구문을 사용합니다.
type Result = T extends U ? X : Y;
// T가 U에 할당 가능하면 X, 아니면 Y여기서 extends는 "할당 가능성(assignability)" 검사를 수행합니다. T가 U에 할당 가능하면 참(true branch), 아니면 거짓(false branch)으로 분기합니다.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // true
// 실용적 예제: null을 제거하는 타입
type NonNullable<T> = T extends null | undefined ? never : T;
type D = NonNullable<string | null>; // string
type E = NonNullable<number | undefined | null>; // number조건부 타입의 가장 중요한 특성은 분배(Distribution) 입니다. 제네릭 타입 파라미터에 유니온 타입이 전달되면, 조건부 타입은 유니온의 각 멤버에 대해 개별적으로 적용됩니다.
type ToArray<T> = T extends any ? T[] : never;
// 유니온이 전달되면 분배됨
type Result = ToArray<string | number>;
// = (string extends any ? string[] : never) | (number extends any ? number[] : never)
// = string[] | number[]
// 분배가 없다면 결과는 (string | number)[] 가 됨이 분배 동작은 네이키드 타입 파라미터(naked type parameter) 에서만 발생합니다. 타입 파라미터가 래핑되면 분배가 차단됩니다.
// 분배됨 (네이키드)
type Distributed<T> = T extends any ? T[] : never;
type R1 = Distributed<string | number>; // string[] | number[]
// 분배 차단 (튜플로 래핑)
type NotDistributed<T> = [T] extends [any] ? T[] : never;
type R2 = NotDistributed<string | number>; // (string | number)[]분배를 의도적으로 차단하고 싶을 때는 [T] extends [U] 패턴을 사용합니다. 이는 타입을 1-튜플로 감싸서 네이키드 타입 파라미터가 아니게 만드는 관용적 테크닉입니다.
type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;
type Animals = "cat" | "dog" | "fish" | "bird";
type Mammals = Extract<Animals, "cat" | "dog">; // "cat" | "dog"
type NonMammals = Exclude<Animals, "cat" | "dog">; // "fish" | "bird"type MixedTypes = string | number | (() => void) | ((x: number) => string);
type FunctionTypes = Extract<MixedTypes, (...args: any[]) => any>;
// (() => void) | ((x: number) => string)
type NonFunctionTypes = Exclude<MixedTypes, (...args: any[]) => any>;
// string | number조건부 타입은 중첩이 가능하며, 이를 통해 복잡한 타입 분기 로직을 구현할 수 있습니다.
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<() => void>; // "function"
type T3 = TypeName<string[]>; // "object"TypeScript 4.1부터 조건부 타입의 재귀가 허용되었습니다. 이를 통해 배열을 평탄화하거나 깊이 중첩된 타입을 처리할 수 있습니다.
type Flatten<T> = T extends ReadonlyArray<infer U> ? Flatten<U> : T;
type Nested = number[][][];
type Flat = Flatten<Nested>; // number
type DeepArray = (string | (number | boolean[])[])[];
type FlatDeep = Flatten<DeepArray>; // string | number | boolean재귀적 조건부 타입은 TypeScript 컴파일러에 부담을 줄 수 있습니다. 깊이가 깊어지면 "Type instantiation is excessively deep" 에러가 발생합니다. TypeScript는 최대 재귀 깊이를 제한하고 있으며, 일반적으로 50 수준 이내에서 사용하는 것이 안전합니다.
infer 키워드는 조건부 타입의 참(true) 분기에서 타입을 추출하는 데 사용됩니다. 패턴 매칭처럼 타입 구조에서 특정 부분을 캡처합니다.
// 함수의 반환 타입 추출
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R1 = ReturnType<() => string>; // string
type R2 = ReturnType<(x: number) => boolean>; // boolean
// 함수의 매개변수 타입 추출
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type P1 = Parameters<(a: string, b: number) => void>; // [string, number]infer는 타입 구조의 어느 위치에든 배치할 수 있습니다.
// 배열 요소 타입 추출
type ElementType<T> = T extends (infer E)[] ? E : never;
type E1 = ElementType<string[]>; // string
// Promise 내부 타입 추출
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type A1 = Awaited<Promise<string>>; // string
type A2 = Awaited<Promise<Promise<number>>>; // number
// 객체의 값 타입 추출
type ValueOf<T> = T extends Record<string, infer V> ? V : never;
type V1 = ValueOf<{ a: string; b: number }>; // string | numberinfer에 extends 제약 조건을 추가하여 추출되는 타입을 제한할 수 있습니다.
// 첫 번째 요소가 string인 경우만 추출
type FirstString<T> =
T extends [infer S extends string, ...any[]] ? S : never;
type FS1 = FirstString<["hello", 1, 2]>; // "hello"
type FS2 = FirstString<[42, "hi"]>; // never
// 숫자 키만 추출
type NumericKeys<T> = {
[K in keyof T]: K extends `${infer N extends number}` ? N : never;
}[keyof T];
type NK = NumericKeys<["a", "b", "c"]>; // 0 | 1 | 2type ApiResponse<T> =
| { status: "success"; data: T }
| { status: "error"; error: string };
// 성공 응답의 데이터 타입 추출
type ExtractData<T> =
T extends ApiResponse<infer D> ? D : never;
type UserResponse = ApiResponse<{ id: string; name: string }>;
type UserData = ExtractData<UserResponse>;
// { id: string; name: string }interface EventMap {
click: { x: number; y: number };
keydown: { key: string; code: string };
scroll: { scrollTop: number; scrollLeft: number };
}
type EventHandler<T> = T extends keyof EventMap
? (event: EventMap[T]) => void
: never;
type ClickHandler = EventHandler<"click">;
// (event: { x: number; y: number }) => void
type KeyHandler = EventHandler<"keydown">;
// (event: { key: string; code: string }) => voidtype DeepReadonly<T> =
T extends (...args: any[]) => any ? T :
T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> :
T extends Set<infer U> ? ReadonlySet<DeepReadonly<U>> :
T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepReadonly<U>> :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
interface MutableState {
users: {
name: string;
settings: {
theme: string;
notifications: boolean;
};
}[];
}
type ImmutableState = DeepReadonly<MutableState>;
// 모든 중첩 프로퍼티가 readonlytype PathValue<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? PathValue<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
interface Config {
server: {
port: number;
host: string;
ssl: {
enabled: boolean;
cert: string;
};
};
database: {
url: string;
};
}
type Port = PathValue<Config, "server.port">; // number
type SSLEnabled = PathValue<Config, "server.ssl.enabled">; // boolean
type DbUrl = PathValue<Config, "database.url">; // stringTypeScript 컴파일러는 조건부 타입을 지연 평가(lazy evaluation) 합니다. 제네릭 파라미터가 구체화되지 않으면 조건부 타입은 평가되지 않고 그대로 유지됩니다.
function processValue<T>(value: T): T extends string ? number : boolean {
// 컴파일러는 T가 무엇인지 모르므로 조건부 타입을 평가하지 않음
// 따라서 내부에서 직접 반환하기 어려움
if (typeof value === "string") {
return value.length as any; // 타입 단언 필요
}
return true as any;
}제네릭 함수 내부에서 조건부 타입의 반환을 처리하는 것은 TypeScript의 알려진 한계입니다. 함수 오버로드나 타입 단언을 사용하여 이를 해결할 수 있습니다. TypeScript 5.x에서도 이 제한은 여전하며, 향후 개선이 논의되고 있습니다.
function processValue(value: string): number;
function processValue(value: number): boolean;
function processValue(value: string | number): number | boolean {
if (typeof value === "string") {
return value.length;
}
return true;
}
const a = processValue("hello"); // number
const b = processValue(42); // boolean조건부 타입은 TypeScript 타입 시스템의 핵심 표현 도구입니다. extends 키워드를 통한 타입 분기, 분배적 조건부 타입의 유니온 처리, infer를 활용한 타입 추출은 라이브러리 타입 설계의 기반이 됩니다. 재귀적 조건부 타입과 infer 제약 조건(4.7+)은 이 표현력을 더욱 확장합니다.
다음 장에서는 타입 시스템의 또 다른 핵심인 매핑 타입을 다룹니다. 기존 타입을 기반으로 새로운 타입을 생성하는 매핑 타입과 키 재매핑 기법을 배웁니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 매핑 타입의 원리, 수정자 조작, 키 재매핑(as 절), 그리고 조건부 타입과의 결합 패턴을 실전 예제와 함께 다룹니다.
TypeScript 5.2에서 도입된 using 선언과 Symbol.dispose를 활용한 명시적 리소스 관리 패턴을 실전 예제와 함께 심층 분석합니다.
TypeScript 템플릿 리터럴 타입의 원리, 내장 문자열 조작 유틸리티, 패턴 매칭, 그리고 매핑 타입과의 결합을 통한 실전 패턴을 다룹니다.