조건부 타입(Conditional Type)은 T extends U ? X : Y 형태로, 타입 레벨에서 if-else 를 구현하는 것입니다.

기본 문법

TYPESCRIPT
// 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는 "할당 가능한가"를 의미합니다. stringstring에 할당 가능하므로 참입니다.

분배 법칙(Distributive Conditional Types)

유니온 타입에 조건부 타입을 적용하면 각 멤버에 개별적으로 적용됩니다.

TYPESCRIPT
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)[]가 아님!

분배를 막으려면

TYPESCRIPT
// 대괄호로 감싸면 분배가 일어나지 않음
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;

type Result2 = ToArrayNonDistributive<string | number>;
// = (string | number)[]

공부하다 보니 이 분배 법칙이 조건부 타입에서 가장 헷갈리는 부분이더라고요. 면접에서도 "왜 string[] | number[]이지 (string | number)[]가 아닌가요?"라고 물어봅니다.

실전 패턴

타입 판별

TYPESCRIPT
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 처리

TYPESCRIPT
// null | undefined를 제거하면서 변환
type NonNullableMap<T> = T extends null | undefined ? never : T;

type Result = NonNullableMap<string | null | undefined>;
// string

재귀 조건부 타입

TYPESCRIPT
// 깊은 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
// TypeScript 내장 구현
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type NonNullable<T> = T & {};

중첩 조건부 타입

TYPESCRIPT
// 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와 조건부 타입

TYPESCRIPT
// 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는 빈 유니온이므로 분배 시 특별하게 동작한다
댓글 로딩 중...