조건부 타입과 infer 키워드
타입도 삼항 연산자로 분기할 수 있다
조건부 타입(conditional type)은 T extends U ? X : Y 형태로 씁니다. 값의 세계에서 condition ? a : b가 하는 일을 타입의 세계에서 하는 겁니다.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false여기서 extends는 앞 챕터에서 본 제약의 extends와 문법은 같지만 의미가 다릅니다. 제약의 extends는 "이 타입은 반드시 저것과 호환돼야 한다"는 강제이고, 조건부 타입의 extends는 "이 타입이 저것과 호환되는가?"를 묻는 질문입니다. 이 둘을 같은 키워드가 맡고 있다는 게 TypeScript를 처음 배울 때 혼란스러운 지점 중 하나입니다.
infer로 타입의 일부를 뽑아내기
조건부 타입의 진짜 힘은 infer와 함께일 때 나옵니다. infer는 "조건이 참이라면, 그 안의 이 위치에 있는 타입을 변수로 캡처해라"는 뜻입니다.
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<boolean>; // never, 배열이 아니므로T extends (infer U)[]는 "T가 어떤 타입의 배열이라면, 그 원소 타입을 U라는 이름으로 붙잡아라"는 뜻입니다. T가 string[]이면 U는 string이 되고, 조건이 참이므로 결과는 U, 즉 string이 됩니다. 사실 이 타입은 TypeScript에 이미 내장되어 있어서(T[number]로도 같은 걸 할 수 있지만) infer의 동작 방식을 보여주기엔 가장 좋은 예시입니다.
실무에서 훨씬 자주 쓰는 예시는 함수의 반환 타입을 뽑아내는 것입니다.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function fetchUser() {
return { id: 1, name: "eun" };
}
type User = MyReturnType<typeof fetchUser>;
// { id: number; name: string }typeof fetchUser로 함수의 타입을 얻고, MyReturnType이 그 함수의 반환 타입만 뽑아냅니다. 이건 TypeScript 내장 유틸리티 타입 ReturnType<T>와 정확히 같은 동작입니다. 함수의 반환값 타입을 인터페이스로 손으로 옮겨적을 필요 없이, 함수 정의 자체를 원천으로 삼아 타입을 파생시킬 수 있다는 게 핵심입니다.
유니온을 만나면 분배된다 — 의도치 않게 자주 걸리는 함정
조건부 타입에서 가장 많이 헷갈리는 동작이 분배 조건부 타입(distributive conditional type)입니다. T가 유니온이고 조건부 타입의 검사 대상이 그냥 T 하나뿐이면, TypeScript는 유니온을 하나씩 쪼개서 각각에 조건을 적용한 뒤 다시 합칩니다.
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] ← (string | number)[] 가 아니다!ToArray<string | number>를 실행하면, 컴파일러는 내부적으로 ToArray<string> | ToArray<number>로 나눠 계산한 뒤 string[] | number[]로 합칩니다. (string | number)[], 즉 "문자열과 숫자가 섞인 배열 하나"를 기대했다면 이건 놀라운 결과입니다. 이 분배를 막고 싶다면 T를 대괄호로 감싸면 됩니다.
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// (string | number)[][T]로 감싸면 컴파일러가 보는 건 "튜플 하나"이지 유니온 자체가 아니게 되므로, 분배가 일어나지 않습니다. 이 패턴([T] extends [U])은 트릭처럼 보이지만 TypeScript 표준 라이브러리에도 실제로 쓰이는 관용구입니다.
flowchart TD
A["T extends U ? X : Y 를 T = A | B에 적용"] --> B{"T가 검사 대상 위치에\n그대로(bare) 나타나는가?"}
B -->|예, 분배됨| C["(A extends U ? X : Y) | (B extends U ? X : Y)"]
B -->|아니오, [T]로 감쌈| D["[A | B] extends [U] ? X : Y 를\n하나의 단위로 검사"]언제 조건부 타입까지 손을 대야 하는가
조건부 타입과 infer는 라이브러리 저자에게는 필수지만, 애플리케이션 코드에서는 자주 쓸 일이 없습니다. 실무에서 이 문법을 직접 쓰게 되는 경우는 대개 세 가지입니다 — 서드파티 함수의 반환 타입을 이름 붙여 재사용하고 싶을 때, 유니온 타입 중 특정 조건을 만족하는 것만 걸러내고 싶을 때, 그리고 라이브러리의 타입 정의가 부족해서 직접 보강해야 할 때. 이 세 상황이 아니라면, 조건부 타입을 쓰기 전에 앞서 다룬 Pick·Omit·매핑된 타입 조합으로 충분한지부터 확인하는 게 낫습니다. 도구가 강력하다고 항상 꺼내 쓸 이유는 없습니다.