본문으로 건너뛰기
Advertisement

5.4 조건부 타입

조건부 타입이란

조건부 타입(Conditional Types)은 타입 레벨에서 if-else를 표현합니다. 입력 타입에 따라 결과 타입이 달라집니다.

// 기본 문법
type ConditionalType = T extends U ? X : Y;
// ^^^^^^^^^^^^^^^^
// "T가 U에 할당 가능하면 X, 아니면 Y"

JavaScript의 삼항 연산자와 비슷하지만, 값이 아닌 타입을 다룹니다.

// 간단한 예시
type IsString<T> = T extends string ? "yes" : "no";

type Test1 = IsString<string>; // "yes"
type Test2 = IsString<number>; // "no"
type Test3 = IsString<"hello">; // "yes" (string 리터럴은 string의 서브타입)

기본 문법과 활용

타입 분기 표현

// null 가능 여부 확인
type IsNullable<T> = null extends T ? true : false;

type A = IsNullable<string | null>; // true
type B = IsNullable<string>; // false
type C = IsNullable<null>; // true

// 배열 여부 확인
type IsArray<T> = T extends unknown[] ? true : false;

type D = IsArray<number[]>; // true
type E = IsArray<string>; // false
type F = IsArray<never[]>; // true
type G = IsArray<readonly number[]>; // false (읽기전용 배열은 unknown[]의 서브타입 아님)

// 함수 여부 확인
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;

type H = IsFunction<() => void>; // true
type I = IsFunction<(x: number) => string>; // true
type J = IsFunction<string>; // false

조건부 타입으로 타입 변환

// 배열 타입이면 요소 타입을, 아니면 그대로 반환
type Flatten<T> = T extends Array<infer Item> ? Item : T;

type Num = Flatten<number[]>; // number
type Str = Flatten<string>; // string (배열 아님 → 그대로)
type Nested = Flatten<string[][]>; // string[] (1단계만 평탄화)

// null/undefined를 제거하는 타입
type NonNullable2<T> = T extends null | undefined ? never : T;

type K = NonNullable2<string | null | undefined>; // string
type L = NonNullable2<number | null>; // number

분산 조건부 타입 (Distributive Conditional Types)

조건부 타입에 유니온 타입이 들어오면 각 멤버에 자동으로 분배됩니다. 이것이 분산 조건부 타입의 핵심입니다.

type IsString<T> = T extends string ? "yes" : "no";

// 유니온 타입 입력 시 분산 적용
type Test = IsString<string | number | boolean>;
// = IsString<string> | IsString<number> | IsString<boolean>
// = "yes" | "no" | "no"
// = "yes" | "no"

분산이 일어나려면 타입 파라미터 T가 "naked type parameter" 여야 합니다. 즉, 제네릭 타입 파라미터가 직접 extends 앞에 와야 합니다.

// 분산 조건부 타입 — Exclude, Extract 구현 원리
type MyExclude<T, U> = T extends U ? never : T;

type Result1 = MyExclude<"a" | "b" | "c" | "d", "b" | "d">;
// = MyExclude<"a", "b" | "d"> // "a"
// | MyExclude<"b", "b" | "d"> // never
// | MyExclude<"c", "b" | "d"> // "c"
// | MyExclude<"d", "b" | "d"> // never
// = "a" | never | "c" | never
// = "a" | "c"

type MyExtract<T, U> = T extends U ? T : never;

type Result2 = MyExtract<"admin" | "user" | "guest", "admin" | "moderator">;
// = "admin"

분산을 활용한 타입 변환

// 유니온의 각 멤버를 래핑
type Wrap<T> = T extends any ? { value: T } : never;

type Wrapped = Wrap<string | number>;
// = { value: string } | { value: number }

// 유니온의 각 멤버를 배열로
type ToArray<T> = T extends any ? T[] : never;

type ArrayUnion = ToArray<string | number>;
// = string[] | number[] (not (string | number)[])

// 함수 타입으로 변환
type ToFunction<T> = T extends any ? () => T : never;

type FunctionUnion = ToFunction<string | number | boolean>;
// = (() => string) | (() => number) | (() => boolean)

infer 키워드

infer는 조건부 타입 내에서 타입을 추론하고 이름을 붙입니다. infer R은 "여기서 타입을 추론해서 R이라는 이름을 붙인다"는 의미입니다.

// 기본 infer 사용
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;

type N = UnpackArray<number[]>; // number
type S = UnpackArray<string[]>; // string
type M = UnpackArray<string[][]>; // string[] (1단계)

// 함수 반환 타입 추출
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function greet(name: string): string {
return `Hello, ${name}!`;
}

type GreetReturn = MyReturnType<typeof greet>; // string

// 함수 매개변수 타입 추출
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

type GreetParams = MyParameters<typeof greet>; // [name: string]

// 첫 번째 매개변수만 추출
type FirstParameter<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

function createUser(name: string, age: number, email: string): void {}
type FirstArg = FirstParameter<typeof createUser>; // string

Promise 풀기

// Promise<T>에서 T 추출
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type P1 = UnpackPromise<Promise<string>>; // string
type P2 = UnpackPromise<Promise<number[]>>; // number[]
type P3 = UnpackPromise<string>; // string (Promise 아님)

// 중첩 Promise 재귀적으로 풀기
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;

type P4 = DeepUnpackPromise<Promise<Promise<Promise<string>>>>;
// string

// TypeScript 4.5+에서는 Awaited<T>로 대체
type P5 = Awaited<Promise<Promise<string>>>; // string

튜플에서 타입 추출

// 첫 번째 요소 타입
type Head<T extends unknown[]> = T extends [infer H, ...any[]] ? H : never;

type H1 = Head<[string, number, boolean]>; // string
type H2 = Head<[number]>; // number
type H3 = Head<[]>; // never

// 마지막 요소 타입
type Last<T extends unknown[]> = T extends [...any[], infer L] ? L : never;

type L1 = Last<[string, number, boolean]>; // boolean
type L2 = Last<[number]>; // number

// 꼬리 타입 (첫 요소 제외)
type Tail<T extends unknown[]> = T extends [any, ...infer Rest] ? Rest : never;

type T1 = Tail<[string, number, boolean]>; // [number, boolean]
type T2 = Tail<[string]>; // []

조건부 타입 중첩

// 타입을 여러 분기로 분류
type TypeCategory<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends null ? "null" :
T extends undefined ? "undefined" :
T extends any[] ? "array" :
T extends object ? "object" :
"unknown";

type C1 = TypeCategory<string>; // "string"
type C2 = TypeCategory<42>; // "number"
type C3 = TypeCategory<boolean>; // "boolean"
type C4 = TypeCategory<null>; // "null"
type C5 = TypeCategory<number[]>; // "array"
type C6 = TypeCategory<{ a: 1 }>; // "object"

// 읽기 전용 여부에 따른 분기
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};

type IsReadonly<T, K extends keyof T> =
({ [P in K]: T[P] } extends { readonly [P in K]: T[P] }
? true
: false);

분산 비활성화

유니온 분산을 원하지 않을 때는 [T] extends [U] 처럼 타입을 튜플로 감쌉니다.

// 기본: 분산 발생
type IsNever<T> = T extends never ? true : false;

type Test1 = IsNever<never>; // never (분산 때문에 never에서 조건 평가 안 됨)
type Test2 = IsNever<string>; // false

// 수정: 분산 비활성화
type IsNeverFixed<T> = [T] extends [never] ? true : false;

type Test3 = IsNeverFixed<never>; // true (정상 작동)
type Test4 = IsNeverFixed<string>; // false

// 유니온 타입을 분산 없이 처리
type IsUnion<T> = [T] extends [never]
? false
: T extends any
? ([Exclude<T, T>] extends [never] ? false : true)
: never;

// 왜 튜플로 감싸는가?
// T = string | number일 때:
// T extends U → string extends U | number extends U (분산)
// [T] extends [U] → [string | number] extends [U] (분산 없음)
// 실용적인 분산 비활성화 사례
type Distribute<T, U> = T extends U ? T[] : never;
type NoDistribute<T, U> = [T] extends [U] ? T[] : never;

type D1 = Distribute<string | number, string>;
// string[] | never = string[] (분산됨)

type D2 = NoDistribute<string | number, string>;
// never (string | number는 string의 서브타입이 아님)

실전 예제

IsArray<T>

type IsArray<T> = T extends readonly unknown[] ? true : false;

type R1 = IsArray<number[]>; // true
type R2 = IsArray<readonly string[]>; // true
type R3 = IsArray<[1, 2, 3]>; // true (튜플도 배열)
type R4 = IsArray<string>; // false
type R5 = IsArray<{ length: number }>; // false

UnpackPromise<T>

// 재귀적으로 모든 Promise 레이어 제거
type UnpackPromise<T> = T extends Promise<infer Inner>
? UnpackPromise<Inner>
: T;

type U1 = UnpackPromise<Promise<string>>; // string
type U2 = UnpackPromise<Promise<Promise<number[]>>>; // number[]
type U3 = UnpackPromise<string>; // string

// 실용 함수와 결합
async function fetchData(): Promise<{ id: number; name: string }> {
return { id: 1, name: "Alice" };
}

type FetchResult = UnpackPromise<ReturnType<typeof fetchData>>;
// { id: number; name: string }

FunctionReturnType<T>

// 비동기 함수도 지원하는 반환 타입 추출
type FunctionReturnType<T extends (...args: any[]) => any> =
ReturnType<T> extends Promise<infer R>
? R
: ReturnType<T>;

async function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then((r) => r.json());
}

function getVersion(): string {
return "1.0.0";
}

type UserType = FunctionReturnType<typeof fetchUser>; // User (Promise 제거됨)
type VersionType = FunctionReturnType<typeof getVersion>; // string

DeepRequired<T>

// 모든 중첩 프로퍼티를 필수로
type DeepRequired<T> = T extends object
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
: T;

interface Profile {
name?: string;
address?: {
street?: string;
city?: string;
zip?: string;
country?: {
code?: string;
name?: string;
};
};
preferences?: {
theme?: "light" | "dark";
language?: string;
};
}

type StrictProfile = DeepRequired<Profile>;
// {
// name: string;
// address: {
// street: string;
// city: string;
// zip: string;
// country: { code: string; name: string; };
// };
// preferences: { theme: "light" | "dark"; language: string; };
// }

타입 필터링

// 유니온에서 특정 구조만 추출
type FilterByProp<T, K extends string, V> =
T extends Record<K, V> ? T : never;

type Actions =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "REMOVE_TODO"; payload: { id: number } }
| { type: "CLEAR_ALL" }
| { type: "SET_FILTER"; payload: { filter: string } };

// payload가 있는 액션만 추출
type ActionsWithPayload = FilterByProp<Actions, "payload", unknown>;
// { type: "ADD_TODO"; payload: ... }
// | { type: "REMOVE_TODO"; payload: ... }
// | { type: "SET_FILTER"; payload: ... }

// 특정 type 값을 가진 액션 추출
type ExtractAction<T extends { type: string }, K extends T["type"]> =
T extends { type: K } ? T : never;

type AddTodoAction = ExtractAction<Actions, "ADD_TODO">;
// { type: "ADD_TODO"; payload: { text: string } }

고수 팁

조건부 타입 디버깅

// 복잡한 조건부 타입 단계별 분해
type Complex<T> =
T extends string
? T extends `${infer Start}${infer End}`
? [Start, End]
: never
: never;

// 디버깅: 단계별로 분리해서 확인
type Step1<T> = T extends string ? T : never; // string만 통과
type Step2<T extends string> = T extends `${infer S}${infer E}` ? [S, E] : never;

// IDE에서 타입 확인할 때 유용한 패턴
type Debug<T> = { [K in keyof T]: T[K] }; // 타입 펼쳐보기
type DebugComplex = Debug<Complex<"hello">>;

// never 추적
type TraceNever<T> =
[T] extends [never]
? "never 입니다"
: `${string & T} 타입입니다`;

type T1 = TraceNever<never>; // "never 입니다"
type T2 = TraceNever<string>; // string 타입입니다

복잡한 타입 단계별 분해

// 나쁜 패턴: 한 번에 모든 것을 표현
type OverlyComplex<T> =
T extends Array<infer E>
? E extends string
? E extends `${infer P}:${infer Q}`
? { prefix: P; suffix: Q }[]
: string[]
: E extends number
? number[]
: never
: never;

// 좋은 패턴: 의미 있는 단위로 분리
type ElementOf<T> = T extends Array<infer E> ? E : never;
type ParseColonString<S extends string> =
S extends `${infer P}:${infer Q}` ? { prefix: P; suffix: Q } : never;
type ParseStringArray<T extends string[]> = {
[K in keyof T]: T[K] extends string ? ParseColonString<T[K]> : never;
};

// 각 단계를 독립적으로 테스트 가능
type E = ElementOf<string[]>; // string
type P = ParseColonString<"key:value">; // { prefix: "key"; suffix: "value" }

never를 활용한 타입 가드

// 모든 유니온 케이스를 처리했는지 검사
type Exhaustive<T extends never> = T;

function handleAction(action: Actions): string {
switch (action.type) {
case "ADD_TODO":
return `추가: ${action.payload.text}`;
case "REMOVE_TODO":
return `제거: ${action.payload.id}`;
case "CLEAR_ALL":
return "전체 삭제";
case "SET_FILTER":
return `필터 설정: ${action.payload.filter}`;
default:
// 모든 케이스 처리 시 action은 never
const exhausted: Exhaustive<typeof action> = action;
return exhausted;
}
}

// Actions에 새 타입 추가 시 위 switch에서 컴파일 오류 발생

정리 표

개념문법설명
기본 조건부 타입T extends U ? X : YT가 U에 할당 가능하면 X
분산 조건부 타입유니온 T에 자동 적용각 멤버에 개별 분배
분산 비활성화[T] extends [U]튜플로 감싸 분산 방지
infer 기본T extends F<infer R>조건 내 타입 추론·추출
배열 요소 추출T extends Array<infer E>배열 요소 타입 추출
함수 반환 추출T extends (...) => infer R반환 타입 추출
Promise 풀기T extends Promise<infer U>Promise 내 타입 추출
재귀 조건부자기 참조 조건부 타입중첩 구조 처리
never 분산T extends never → never[T] extends [never]로 우회

다음 장에서는...

5.5절에서는 제네릭 실전 패턴을 다룹니다. Repository 패턴, Builder 패턴, API 응답 래퍼, React 제네릭 훅, 제네릭 이벤트 에미터 등 실무에서 바로 활용할 수 있는 설계 패턴들을 제네릭으로 구현합니다. 타입 안전성을 유지하면서 유연한 API를 설계하는 방법을 배웁니다.

Advertisement