지식
JavaScript
조건부 타입 — 타입 수준의 분기 처리
TypeScript 조건부 타입(T extends U ? A : B), infer 키워드, 분배 조건부 타입, NonNullable·Extract·Exclude 내장 유틸리티 구현 원리를 완전히 정리합니다.
지난 글에서 extends 제약과 keyof 활용을 살펴봤다. 이번에는 TypeScript 타입 시스템의 가장 강력한 기능 중 하나인 **조건부 타입(Conditional Types)**을 다룬다. 조건부 타입은 마치 타입 수준의 if/else처럼 동작하며, 이를 통해 TypeScript 표준 라이브러리의 핵심 유틸리티 타입들이 구현되어 있다.
기본 문법
// T extends U ? A : B
// "T가 U를 충족하면 A, 아니면 B"
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)
삼항 연산자처럼 중첩할 수 있다.
type Flatten<T> =
T extends Array<infer Item> ? Item :
T extends object ? keyof T :
T;
type F1 = Flatten<string[]>; // string
type F2 = Flatten<{ a: 1 }>; // "a"
type F3 = Flatten<number>; // number
infer — 조건부 타입 내에서 타입 추출
infer 키워드는 조건부 타입의 extends 절 안에서만 사용할 수 있으며, 매칭된 타입을 변수로 캡처한다.
// 함수 반환 타입 추출
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
// 함수 매개변수 타입 추출
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// Promise 내부 타입 추출
type Awaited<T> =
T extends Promise<infer U>
? Awaited<U> // 재귀: Promise<Promise<string>> → string
: T;
// 실전 사용
async function fetchUser() {
return { id: 1, name: "Alice" };
}
type UserType = Awaited<ReturnType<typeof fetchUser>>;
// { id: number; name: string }
분배 조건부 타입
제네릭 조건부 타입에 유니언을 전달하면 각 멤버에 **분배(distribute)**된다. 이 동작이 기본이며, 다음의 내장 유틸리티 타입들은 이 원리로 구현되어 있다.
// Exclude<T, U> — U에 해당하는 타입 제거
type Exclude<T, U> = T extends U ? never : T;
type WithoutString = Exclude<string | number | boolean, string>;
// number | boolean
// Extract<T, U> — U에 해당하는 타입만 추출
type Extract<T, U> = T extends U ? T : never;
type OnlyString = Extract<string | number | boolean, string>;
// string
// NonNullable<T> — null, undefined 제거
type NonNullable<T> = T extends null | undefined ? never : T;
type Defined = NonNullable<string | null | undefined>;
// string
분배가 일어나는 조건: 타입 매개변수가 그대로 extends 왼쪽에 있을 때만 분배된다. 튜플로 감싸면 분배를 막을 수 있다.
type NoDistribute<T> = [T] extends [string] ? "yes" : "no";
type D1 = NoDistribute<string | number>; // "no" (분배 없음)
type D2 = IsString<string | number>; // "yes" | "no" (분배됨)
내장 유틸리티 타입 구현 원리
// ReturnType
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
// InstanceType — 생성자에서 인스턴스 타입 추출
type InstanceType<T extends abstract new (...args: any) => any> =
T extends abstract new (...args: any) => infer R ? R : any;
class Dog { bark() { return "woof"; } }
type DogInstance = InstanceType<typeof Dog>; // Dog
// Parameters
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number): string {
return `${name} (${age})`;
}
type GreetParams = Parameters<typeof greet>; // [string, number]
재귀 조건부 타입
TypeScript 4.1부터 재귀 조건부 타입을 지원한다.
// 중첩 배열 평탄화
type DeepFlatten<T> =
T extends (infer Item)[] ? DeepFlatten<Item> : T;
type DF = DeepFlatten<number[][][]>; // number
// 객체를 점(.) 표기법 키로 변환
type Paths<T, Key extends keyof T = keyof T> =
Key extends string
? T[Key] extends object
? `${Key}.${Paths<T[Key]>}` | Key
: Key
: never;
type Config = { server: { host: string; port: number }; debug: boolean };
type ConfigPaths = Paths<Config>;
// "server" | "debug" | "server.host" | "server.port"
실전 패턴 — API 함수 타입 추론
// HTTP 메서드별 응답 타입 추론
type ApiResponse<T extends string> =
T extends "GET" ? { data: unknown; status: number } :
T extends "POST" ? { created: true; id: number } :
T extends "DELETE" ? { deleted: true } :
never;
function request<M extends "GET" | "POST" | "DELETE">(
method: M,
url: string
): Promise<ApiResponse<M>> {
return fetch(url, { method }) as any;
}
const getRes = await request("GET", "/api/users");
// { data: unknown; status: number }
const postRes = await request("POST", "/api/users");
// { created: true; id: number }
조건부 타입의 강력함은 여기서 드러난다. 함수를 하나만 정의했는데 메서드에 따라 반환 타입이 자동으로 달라진다.
지난 글: 제네릭 제약 — extends와 keyof
읽어주셔서 감사합니다. 😊