프론트엔드

타입스크립트의 조건부 타입

도우 2026. 3. 7. 11:00
728x90

타입에 조건을 걸 수 있다.
"이 타입이 저 타입을 확장하면 A, 아니면 B"라는 식의 분기를 타입 수준에서 표현하는 것이 조건부 타입의 핵심이다.


조건부 타입의 기본 구조

조건부 타입은 삼항 연산자와 동일한 형태를 사용한다.

type A = number extends string ? string : number;
// number는 string을 확장하지 않으므로 → number

T extends U ? X : Y 형태로 읽으면 된다. T가 U의 서브타입이면 X, 아니면 Y
extends는 상속이 아니라 부분집합 관계, 즉 호환 가능 여부를 묻는 것이다.

type ObjA = { a: number };
type ObjB = { a: number; b: number };

type B = ObjB extends ObjA ? number : string;
// ObjB는 ObjA의 모든 프로퍼티를 갖고 있으므로 ObjA의 서브타입 → number

이 정도로는 그냥 타입을 하나 더 선언하는 것과 크게 다르지 않아 보인다. 조건부 타입이 진짜 강력해지는 건 제네릭과 결합했을 때이다.


제네릭과 조건부 타입

제니릭 타입 변수 T에 조건부 타입을 걸면, T에 어떤 타입이 들어오느냐에 따라 결과 타입이 달라진다.

type StringNumberSwitch<T> = T extends number ? string : number;

let varA: StringNumberSwitch<number>; // string
let varB: StringNumberSwitch<string>; // number

T가 number이면 string을, number가 아니면 number를 반환한다. 타입이 런타임 값처럼 분기된다.

이 패턴은 함수에서도 활용할 수 있다. 입력 타입에 따라 반환 타입을 다르게 지정하고 싶을때다.

function removeSpaces<T>(text: T): T extends string ? string : undefined;
function removeSpaces<T>(text: T) {
  if (typeof text === "string") {
    return text.replaceAll(" ", "");
  }
  return undefined;
}

let result = removeSpaces("hi hello");
result.toUpperCase(); // string으로 추론됨

let result2 = removeSpaces(undefined);
// undefined로 추론됨

함수 오버로드 시그니처 자리에 조건부 타입을 선언하면, 호출하는 쪽에서는 입력 타입에 따라 정확한 반환 타입을 얻을 수 있다.
함수 본문 내부에서는 조건부 타입이 아직 결정되지 않는 상태라 replaceAll 같은 메서드를 직접 호출할 수 없지만, typeof 체크로 타입을 좁히면 정상적으로 사용할 수 있다.


분산적 조건부 타입

조건부 타입에 유니온을 넣으면 예상과 다르게 동작한다.

type StringNumberSwitch<T> = T extends number ? string : number;

let c: StringNumberSwitch<number | string>;

number | string이 통째로 number extends number를 검사할 것 같지만, 실제로는 유니온의 각 멤버가 개별적으로 조건부 타입을 통과한 뒤 결과가 다시 유니온으로 합쳐진다.

StringNumberSwitch<number | string>
→ StringNumberSwitch<number> | StringNumberSwitch<string>
→ string | number

이것을 분산적 조건부 타입(Distributive Conditional Type) 이라고 한다.
제네릭 타입 변수 T에 유니온이 들어오면 자동으로 분산된다.

이 동작이 실용적으로 어디에 쓰이는지 직접 구현해보면 바로 이해된다.

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

type A = Exclude<number | string | boolean, string>;

분산 과정을 풀어보면 다음과 같다.

Exclude<number, string>  → number  (number는 string이 아니므로)
Exclude<string, string>  → never   (string은 string이므로 제거)
Exclude<boolean, string> → boolean (boolean은 string이 아니므로)

결과: number | never | boolean

never는 공집합이라 유니온에 포함되어도 사라진다. 최종 결과는 number | boolean.
T 중에서 U에 해당하는 타입만 골라 제거하는 Exclude가 이 원리로 작동한다.

반대로 U에 해당하는 타입만 남기는 Extract는 조건을 뒤집으면 된다.

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

type B = Extract<number | string | boolean, string>;
// string

infer 

infer는 조건부 타입 내부에서 특정 위치의 타입을 추론해서 변수처럼 캡처하는 키워드다.
말로만 들으면 추상적이니 바로 예시를 보자.

함수의 반환 타입을 추출하는 타입을 만들고 싶다고 가정한다.

type FuncA = () => string;
type FuncB = () => number;

infer 없이는 반환 티입을 "string인 경우", "numbe 인 경우" 식으로 하드코딩해야 한다.
어떤 타입이 올지 모르는 상황에서는 쓸 수 없다.

type ReturnType<T> = T extends () => infer R ? R : never;

infer R는 "T가 어떤 값을 반환하는 함수라면, 그 반환 타입을 R이라고 부르겠다"는 선언이다.
조건이 참이 될 때 타입스크립트가 R을 실제 반환 타입으로 추론해주고, 그 R을 결과로 내보낸다.

type A = ReturnType<FuncA>; // string
type B = ReturnType<FuncB>; // number
type C = ReturnType<number>; // never — number는 함수가 아니라 추론 불가

infer가 위치하는 자리가 핵심이다. () => infer R에서 R은 반환 타입 자리에 놓여 있기 때문에 반환 타입이 추론된다.
파라미터 자리에 놓으면 파라미터 타입이 추론된다.

Promise의 내부 타입 추출하기

같은 원리를 Promise에도 적용할 수 있다.

type PromiseUnpack<T> = T extends Promise<infer R> ? R : never;

type PromiseA = PromiseUnpack<Promise<number>>; // number
type PromiseB = PromiseUnpack<Promise<string>>; // string

Promise<infer R>에서 R은 Promise가 resolve할 값의 타입 자리에 놓여있다.
T가 Promise<number>라면 R은 number로 추론된다.
비동기 함수의 반환 타입을 다루는 유틸리티를 만들 때 이 패턴이 자주 등장한다.


정리

조건부 타입은 처음에는 타입 시스템 안에서 if문을 쓰는 것 자체가 낯설게 느껴진다.
하지만 제네릭과 결합하면 "입력에 따라 출력이 달라지는 타입"을 만들 수 있고, 분산적 동작 덕분에 유니온 타입을 자연스럽게 걸러낼 수 있다. infer는 그 위에서 타입의 일부를 변수처럼 꺼내 쓰는 수단이다.

이 세 가지가 합쳐진 형태가 유틸리티 타입들의 실제 구현부다. 타입스크립트가 기본으로 제공하는 Exclude, Extract, ReturnType 같은 타입들이 모두 이 원리로 만들어져 있다.

728x90