Conditional Types — T extends U 패턴 마스터
조건부 타입(Conditional Type)은
T extends U ? X : Y형태로, 타입 레벨에서 if-else 를 구현하는 것입니다.
기본 문법
// T가 U를 확장하면 X, 아니면 Y
type IsString<T> = T extends string ? 'yes' : 'no';
type A = IsString<string>; // 'yes'
type B = IsString<number>; // 'no'
type C = IsString<'hello'>; // 'yes' — 리터럴도 string을 확장
여기서 extends는 "할당 가능한가"를 의미합니다. string은 string에 할당 가능하므로 참입니다.
분배 법칙(Distributive Conditional Types)
유니온 타입에 조건부 타입을 적용하면 각 멤버에 개별적으로 적용됩니다.
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)[]가 아님!
분배를 막으려면
// 대괄호로 감싸면 분배가 일어나지 않음
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDistributive<string | number>;
// = (string | number)[]
공부하다 보니 이 분배 법칙이 조건부 타입에서 가장 헷갈리는 부분이더라고요. 면접에서도 "왜 string[] | number[]이지 (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<42>; // 'number'
type T3 = TypeName<() => void>; // 'function'
type T4 = TypeName<string[]>; // 'object'
Nullable 처리
// null | undefined를 제거하면서 변환
type NonNullableMap<T> = T extends null | undefined ? never : T;
type Result = NonNullableMap<string | null | undefined>;
// string
재귀 조건부 타입
// 깊은 Readonly
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface User {
name: string;
address: {
city: string;
zip: number;
};
}
type ReadonlyUser = DeepReadonly<User>;
// {
// readonly name: string;
// readonly address: {
// readonly city: string;
// readonly zip: number;
// };
// }
조건부 타입과 유니온 필터링
Exclude, Extract, NonNullable은 전부 조건부 타입으로 구현되어 있습니다.
// TypeScript 내장 구현
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type NonNullable<T> = T & {};
중첩 조건부 타입
// API 응답 타입을 상태에 따라 분기
type ApiResult<T, Status extends number> =
Status extends 200 ? { data: T; error: null } :
Status extends 404 ? { data: null; error: 'Not Found' } :
Status extends 500 ? { data: null; error: 'Internal Server Error' } :
{ data: null; error: string };
type SuccessResult = ApiResult<User, 200>;
// { data: User; error: null }
type NotFoundResult = ApiResult<User, 404>;
// { data: null; error: 'Not Found' }
never와 조건부 타입
// never는 빈 유니온이므로 분배 시 아무것도 생성하지 않음
type Test = IsString<never>; // never (분배할 멤버가 없음)
// never 체크를 하려면 분배를 막아야 함
type IsNever<T> = [T] extends [never] ? true : false;
type A = IsNever<never>; // true
type B = IsNever<string>; // false
정리
- 조건부 타입은
T extends U ? X : Y로 타입 레벨 if-else를 구현한다 - 유니온에 적용하면 분배 법칙으로 각 멤버에 개별 적용된다
- 분배를 막으려면
[T] extends [U]로 감싼다 - Exclude, Extract 등 내장 유틸리티는 조건부 타입으로 구현되어 있다
never는 빈 유니온이므로 분배 시 특별하게 동작한다
댓글 로딩 중...