타입스크립트 심화 #3 Conditional types와 infer

#1 keyof와 typeof#2 Mapped types 에서 타입을 가공하는 두 도구를 봤습니다. 이번 글은 그 위에 분기를 더하는 도구 — conditional typesinfer 입니다. 이 둘이 들어오면 빌트인 유틸리티 타입의 거의 전부를 직접 짤 수 있게 됩니다.

기본 — T extends U ? X : Y

문법은 자바스크립트 삼항 연산자와 똑같이 생겼습니다.

conditional type 기본
type IsString<T> = T extends string ? true : false;
 
type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false
type C = IsString<boolean>;  // false

타입 단계의 if문이라고 보면 됩니다. Tstring 의 부분 집합이면 true, 아니면 false.

extends 의 의미가 살짝 헷갈릴 수 있는데, 여기서 A extends B"A는 B에 할당 가능한가" 입니다. 클래스 상속의 extends가 아니라 부분집합 검사예요.

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 타입에 분배됩니다.

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 같은 유틸리티가 단순한 한 줄로 정의됩니다.

Exclude 직접 구현
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutString = MyExclude<string | number | boolean, string>;
// number | boolean

T가 union이라 분배되고, 각 멤버를 검사합니다. stringstring extends string ? never : stringnever. number/boolean 은 그대로 남아요. 결과를 union으로 합치니 string 이 빠진 셈입니다.

never 는 union 안에서 흔적이 사라진다는 점이 핵심이에요. string | never | number 는 실제로 string | number 로 좁혀집니다.

같은 방식으로 Extract 도 만들어집니다.

Extract 직접 구현
type MyExtract<T, U> = T extends U ? T : never;
 
type OnlyString = MyExtract<string | number | boolean, string>;
// string

이전 글에서 살짝 언급한 OmitExclude 가 등장한 이유가 이제 보일 겁니다 — 키 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 | falseboolean 으로 섞여요. 튜플로 감싸면 그게 막힙니다. "이 union이 정확히 string의 부분집합인가" 를 검사할 때 자주 쓰는 관용구예요.

NonNullable — 빌트인이지만 한 줄

nullundefined 를 union에서 빼는 빌트인.

NonNullable
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 — 타입 안에서 변수 선언하기

inferconditional 안에서만 쓸 수 있는 특별한 키워드 입니다. "이 자리의 타입을 변수처럼 잡아 둬" 라는 의미예요.

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의 배열인가? 그러면 그 U를 잡아 둬라" 라는 뜻. T가 배열이면 그 원소 타입을, 아니면 never.

(infer X) 패턴이 타입 안에서 일종의 매칭을 만들어 냅니다. 정규식의 캡처 그룹 같은 역할이에요.

ReturnType 직접 만들기

가장 자주 쓰는 빌트인 중 하나. 함수 타입에서 반환 타입만 끌어내기.

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 직접 만들기

같은 방식으로 인자 타입을 끌어낼 수도 있습니다. 다만 이번엔 단일 타입이 아니라 튜플 이에요.

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()) 같은 코드의 결과 타입을 다룰 때 자주 만나는 자리.

Awaited 직접 구현 (간단 버전)
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>>>;   // number

MyAwaited<U> 를 자기 자신으로 호출. T가 Promise가 아닐 때까지 계속 풀어요. 빌트인 Awaited 의 실제 정의는 thenable 객체까지 다루느라 좀 더 복잡하지만, 핵심 아이디어는 똑같습니다.

infer + extends 제약

infer 자리에 제약을 걸 수도 있습니다.

infer extends 제약
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 로 잡아내요. 타입 단계의 패턴 매칭 이라고 부르기 시작하는 자리입니다.

실전 — 함수 체인의 마지막 반환 타입

조금 어려운 예 하나. 여러 함수를 체인으로 부른 뒤 마지막 함수의 반환 타입을 끌어내기.

LastReturn — 재귀 + infer
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이 들어가면 각 필드를 검사해서 다르게 처리 하는 게 가능해집니다.

값이 함수인 필드만 readonly로
type LockMethods<T> = {
  [K in keyof T]: T[K] extends Function ? Readonly<T[K]> : T[K];
};

또는 선택 prop만 ? 를 빼는 변환.

optional만 모아서 required로
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 같은 빌트인 도우미를 다룹니다. 타입으로 라우트 패턴을 모델링하거나 이벤트 핸들러 이름을 자동 생성하는 자리에 쓰여요.