타입스크립트 심화 #3 Conditional types와 infer
#1 keyof와 typeof 와 #2 Mapped types 에서 타입을 가공하는 두 도구를 봤습니다. 이번 글은 그 위에 분기를 더하는 도구 — conditional types 와 infer 입니다. 이 둘이 들어오면 빌트인 유틸리티 타입의 거의 전부를 직접 짤 수 있게 됩니다.
기본 — T extends U ? X : Y
문법은 자바스크립트 삼항 연산자와 똑같이 생겼습니다.
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
type C = IsString<boolean>; // false타입 단계의 if문이라고 보면 됩니다. T 가 string 의 부분 집합이면 true, 아니면 false.
extends 의 의미가 살짝 헷갈릴 수 있는데, 여기서 A extends B 는 "A는 B에 할당 가능한가" 입니다. 클래스 상속의 extends가 아니라 부분집합 검사예요.
type T1 = 'hello' extends string ? true : false; // true
type T2 = string extends 'hello' ? true : false; // false
type T3 = 'red' extends 'red' | 'blue' ? true : false; // true
type T4 = 'red' | 'blue' extends 'red' ? true : false; // false (왜? — 곧 설명)T4가 의외인데, 이건 다음 절에서 설명합니다.
분배 조건부 (distributive conditional types)
타입스크립트의 conditional type은 한 가지 특이한 동작이 있습니다 — union 타입에 분배됩니다.
type Naked<T> = T extends string ? 'yes' : 'no';
type Result = Naked<'hello' | 42 | true>;
// 'yes' | 'no' | 'no'
// = 'yes' | 'no''hello' | 42 | true 가 통째로 평가되는 게 아니라, 각 멤버에 대해 따로 평가됩니다. 그 결과가 다시 union으로 합쳐져요. 이걸 분배 조건부 라고 부릅니다.
이 동작 덕분에 Exclude 같은 유틸리티가 단순한 한 줄로 정의됩니다.
type MyExclude<T, U> = T extends U ? never : T;
type WithoutString = MyExclude<string | number | boolean, string>;
// number | booleanT가 union이라 분배되고, 각 멤버를 검사합니다. string 은 string extends string ? never : string → never. number/boolean 은 그대로 남아요. 결과를 union으로 합치니 string 이 빠진 셈입니다.
never 는 union 안에서 흔적이 사라진다는 점이 핵심이에요. string | never | number 는 실제로 string | number 로 좁혀집니다.
같은 방식으로 Extract 도 만들어집니다.
type MyExtract<T, U> = T extends U ? T : never;
type OnlyString = MyExtract<string | number | boolean, string>;
// string이전 글에서 살짝 언급한 Omit 에 Exclude 가 등장한 이유가 이제 보일 겁니다 — 키 union에서 빼야 할 키들을 빼낼 때 분배 조건부가 필요했어요.
분배를 막고 싶을 때 — [T] extends [U]
가끔은 union을 통째로 한 번에 평가하고 싶습니다. 타입을 튜플로 감싸면 분배가 멈춥니다.
type IsExactlyString<T> = [T] extends [string] ? true : false;
type X = IsExactlyString<string | number>; // false (한 번에 평가)
type Y = IsExactlyString<string>; // true위 분배 버전은 union 멤버 각각에 대해 평가되니 결과가 true | false 즉 boolean 으로 섞여요. 튜플로 감싸면 그게 막힙니다. "이 union이 정확히 string의 부분집합인가" 를 검사할 때 자주 쓰는 관용구예요.
NonNullable — 빌트인이지만 한 줄
null 과 undefined 를 union에서 빼는 빌트인.
type MyNonNullable<T> = T extends null | undefined ? never : T;
type X = MyNonNullable<string | null | undefined>; // string
type Y = MyNonNullable<number | undefined>; // number분배 + never 패턴의 또 다른 응용. 유틸리티 타입이 점점 익숙해지면 "이건 어떻게 만들어졌지?" 하는 질문에 5초 안에 답이 나옵니다.
infer — 타입 안에서 변수 선언하기
infer 는 conditional 안에서만 쓸 수 있는 특별한 키워드 입니다. "이 자리의 타입을 변수처럼 잡아 둬" 라는 의미예요.
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의 배열인가? 그러면 그 U를 잡아 둬라" 라는 뜻. T가 배열이면 그 원소 타입을, 아니면 never.
이 (infer X) 패턴이 타입 안에서 일종의 매칭을 만들어 냅니다. 정규식의 캡처 그룹 같은 역할이에요.
ReturnType 직접 만들기
가장 자주 쓰는 빌트인 중 하나. 함수 타입에서 반환 타입만 끌어내기.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 'u1', name: '커티스' };
}
type User = MyReturnType<typeof getUser>;
// { id: string; name: string }(...args: any[]) => infer R — "어떤 인자라도 받아서 R을 반환하는 함수라면, 그 R을 잡아 줘". T 가 함수면 그 반환 타입이 R 에 잡혀요. 빌트인 정의가 사실상 이 한 줄입니다.
Parameters 직접 만들기
같은 방식으로 인자 타입을 끌어낼 수도 있습니다. 다만 이번엔 단일 타입이 아니라 튜플 이에요.
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number) {
return `${name} is ${age}`;
}
type Args = MyParameters<typeof greet>;
// [name: string, age: number]...args: infer P — "rest 매개변수 자리를 통째로 P에 잡아 줘". 함수가 받는 모든 인자를 튜플로 들고 와요.
이게 왜 유용하냐면 — 다른 함수에 같은 인자를 받게 하고 싶을 때 매번 안 적어도 됩니다.
function logCall<F extends (...args: any[]) => any>(
fn: F,
...args: Parameters<F>
): ReturnType<F> {
console.log('호출:', fn.name, args);
return fn(...args);
}
logCall(greet, '커티스', 30); // OK, 반환은 string
logCall(greet, 30, '커티스'); // ✗ 인자 순서 틀림세 줄짜리 wrapper 함수에 원본 함수의 시그니처가 그대로 보존됩니다. 인자 검사도, 반환 타입도 모두 정확하게 동작해요.
Awaited — Promise 풀어내기
fetch().then(r => r.json()) 같은 코드의 결과 타입을 다룰 때 자주 만나는 자리.
type MyAwaited<T> = T extends Promise<infer U> ? U : T;
type A = MyAwaited<Promise<string>>; // string
type B = MyAwaited<string>; // string (Promise가 아니면 그대로)
type C = MyAwaited<Promise<Promise<number>>>; // Promise<number> ← 이거는 한 번만 풀림C가 의외인데, 한 번만 푸는 단순 버전이라 두 겹 Promise는 다 안 벗겨져요. 빌트인 Awaited 는 재귀적으로 풀어냅니다.
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
type C = MyAwaited<Promise<Promise<number>>>; // numberMyAwaited<U> 를 자기 자신으로 호출. T가 Promise가 아닐 때까지 계속 풀어요. 빌트인 Awaited 의 실제 정의는 thenable 객체까지 다루느라 좀 더 복잡하지만, 핵심 아이디어는 똑같습니다.
infer + extends 제약
infer 자리에 제약을 걸 수도 있습니다.
type FirstString<T> = T extends [infer U extends string, ...any[]]
? U
: never;
type A = FirstString<['hello', 42, true]>; // 'hello'
type B = FirstString<[42, 'hello']>; // never (첫 원소가 string이 아님)타입스크립트 4.7부터 가능해진 문법입니다. 튜플의 첫 원소가 string 일 때만 그 자리를 U 로 잡아내요. 타입 단계의 패턴 매칭 이라고 부르기 시작하는 자리입니다.
실전 — 함수 체인의 마지막 반환 타입
조금 어려운 예 하나. 여러 함수를 체인으로 부른 뒤 마지막 함수의 반환 타입을 끌어내기.
type LastReturn<T extends ((...args: any[]) => any)[]> =
T extends [...any[], infer Last extends (...args: any[]) => any]
? ReturnType<Last>
: never;
const fns = [
(x: number) => x * 2,
(x: number) => x + 1,
(x: number) => `result: ${x}`,
] as const;
type Final = LastReturn<typeof fns>; // string튜플의 마지막 원소를 Last 로 잡고, 그 ReturnType 을 끌어냅니다. [...any[], infer Last] 가 핵심 — "앞에 뭐가 있든 상관없고, 마지막을 잡아라". 이런 패턴이 라이브러리 타입을 짤 때 종종 등장합니다.
conditional + mapped — 함께 쓰면 표현력 폭발
#2 에서 DeepReadonly 를 살짝 봤죠. conditional이 들어가면 각 필드를 검사해서 다르게 처리 하는 게 가능해집니다.
type LockMethods<T> = {
[K in keyof T]: T[K] extends Function ? Readonly<T[K]> : T[K];
};또는 선택 prop만 ? 를 빼는 변환.
type RequireOptional<T> = {
[K in keyof T as undefined extends T[K] ? K : never]-?: T[K];
};
type Form = { id: string; nickname?: string; age?: number };
type Filled = RequireOptional<Form>;
// { nickname: string; age: number }as 절에서 conditional을 써서 키를 거르고, modifier로 optional을 벗겨내요. 도구가 모이면 표현이 자연스러워집니다.
함정 — extends 는 분배가 기본, 의식해야 한다
분배 동작을 잊으면 결과가 예상과 다를 수 있습니다.
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[] | number>;
// boolean (= true | false)union 멤버 각각이 따로 평가돼서 true | false 가 됐어요. 의도가 "전체가 한 번에 평가" 였다면 튜플로 감싸야 합니다.
type IsArrayStrict<T> = [T] extends [any[]] ? true : false;
type A = IsArrayStrict<string[] | number>; // false분배가 필요한지 아닌지 — 매번 "이 conditional은 union 멤버에 분배되어야 하나?" 를 의식해야 합니다. 분배가 기본 동작이고, 멈추는 건 명시적이어야 한다고 기억해 두세요.
마무리
이번 글에서 정리한 내용:
- conditional type —
T extends U ? X : Y - 분배 조건부 — union이 자동으로 분배.
Exclude/Extract의 동작 원리 - 분배 막기 —
[T] extends [U] NonNullable도 분배 +never한 줄infer— conditional 안에서 타입 변수처럼 잡아두기ReturnType<T>,Parameters<T>,Awaited<T>모두 한 줄짜리 conditional + infer[...any[], infer Last]같은 튜플 패턴 매칭- conditional + mapped 결합으로 키별 다른 변환
다음 글(#4 Template literal types)에서는 문자열 타입을 합성하는 도구 — `${...}` 패턴과 Capitalize / Uppercase 같은 빌트인 도우미를 다룹니다. 타입으로 라우트 패턴을 모델링하거나 이벤트 핸들러 이름을 자동 생성하는 자리에 쓰여요.