2026년 8월 18일
TS 조건부 타입 — boolean extends true가 두 번 다른 답을 내는 이유
같은 비교인데 답이 다르다
type Direct = boolean extends true ? 'Y' : 'N';
type ViaParam<T> = T extends true ? 'Y' : 'N';
type Indirect = ViaParam<boolean>;여기서 Direct는 'N'이다. 하지만 Indirect는 'Y' | 'N'이 나온다. 둘의 차이는 타입 파라미터를 거쳤는지 여부 하나뿐이다.
예상과 다른 결과
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;이것은 (string | number)[]로 예상할 수 있는데 실제로는 string[] | number[]로 나온다.
extends는 if가 아니다
타입은 값의 집합이다
'hello' extends string // true
string extends 'hello' // false
42 extends number // true
never extends string // true
'a' extends 'a' | 'b' // true
'a' | 'b' extends 'a' // falseextends는 단순히 if문이 아니고 부분집합인가를 확인하는 것이다.
X extends Y ? A : B이런 문법이 있다고 했을 때 X가 Y의 부분집합이면 A 아니면 B이다. 그리고 unknown과 never는 각각 전체집합, 공집합에 대응한다. 그래서 never extends string이 true다. 공집합에는 오른쪽에 없는 원소가 애초에 없어서 반례를 만들 수 없다.
방향이 뒤집히면 답도 뒤집힌다
declare let s: string;
declare let h: 'hello';
s = h; // OK
h = s; // 에러extends는 대칭이 아니라서 방향을 바꾸면 답이 달라질 수 있다. 위 예시를 보면 'hello'는 string의 부분집합이라서 s = h는 문제 없지만 그 반대는 성립하지 않는다.
두 타입이 서로의 부분집합이면 양방향 모두 true다. string extends string이 그렇다.
객체는 속성이 많은 쪽이 부분집합
{ a: 1; b: 2 } extends { a: 1 } // true
{ a: 1 } extends { a: 1; b: 2 } // false객체에서 좀 헷갈릴 수 있는데, 간단하게 설명하면 왼쪽 객체가 오른쪽 객체의 속성을 다 갖고 있는가를 확인하면 된다. 왼쪽에만 있는 추가 속성은 판정에 영향을 주지 않는다. 다만 양쪽에 같이 있는 속성은 타입이 호환돼야 한다. { a: 1; b: 2 } extends { a: 2 }는 a가 어긋나서 false다.
속성이 많은 쪽이 부분집합인 이유는 집합으로 보면 풀린다. { a: 1 }은 "a가 1인 모든 객체"라서 { a: 1 }, { a: 1; b: 2 }, { a: 1; z: 9 }를 전부 포함한다. 제약이 하나 늘어날 때마다 집합은 좁아진다.
분배 법칙
규칙
X extends Y ? A : BX가 타입 파라미터 그 자체이고 거기에 유니온(|) 타입이 오면 분배 법칙이 적용된다. "그 자체"라는 조건이 중요한데, 이건 다음 섹션에서 다룬다. 그리고 여기서 말하는 유니온은 멤버가 0개인 경우까지 포함한다. never가 그 경우이고, 마지막 섹션에서 따로 다룬다.
그래서 맨 처음에 나왔던 Indirect의 타입은 'Y' | 'N'이다. (참고로 boolean은 true | false와 동치다.)
추적해 보기
type ViaParam<T> = T extends true ? 'Y' : 'N';
type Indirect = ViaParam<boolean>;Indirect
= ViaParam<boolean>
= ViaParam<true | false>
= ViaParam<true> | ViaParam<false> 쪼갠다
= (true extends true ? 'Y' : 'N')
| (false extends true ? 'Y' : 'N') 각각 평가한다
= 'Y' | 'N' 합친다type Direct = boolean extends true ? 'Y' : 'N';Direct
= (true | false) extends true ? 'Y' : 'N'
= 'N'Indirect에서 파라미터로 들어온 boolean(= true | false)은 각각 ViaParam<true> | ViaParam<false>로 분배된다. 하지만 Direct는 검사 대상이 타입 파라미터가 아니기 때문에 분배되지 않고 그대로 사용된다.
언제 분배되고 언제 안 되나
naked type parameter
type Naked<T> = T extends true ? 'Y' : 'N'; // Naked<boolean> = 'Y' | 'N'
type Tupled<T> = [T] extends [true] ? 'Y' : 'N'; // Tupled<boolean> = 'N'
type Arrayed<T> = T[] extends true[] ? 'Y' : 'N'; // Arrayed<boolean> = 'N'
type Wrapped<T> = { v: T } extends { v: true } ? 'Y' : 'N'; // Wrapped<boolean> = 'N'naked, 즉 타입 파라미터가 extends 왼쪽 자리에 아무것도 걸치지 않은 채 놓여 있으면 분배 법칙이 적용된다. 왼쪽 자리라는 조건도 필요해서, true extends T처럼 오른쪽에 놓이면 분배되지 않는다.
아래 세 줄이 모두 'N'인 이유는 같다. 튜플은 원소별로, 배열은 원소 타입끼리, 객체는 같은 이름 속성끼리 비교하기 때문에 결국 boolean extends true로 내려가고, 이 세 자리에서는 그 답이 그대로 올라온다. 감싸기가 분배를 끈다는 점은 언제나 같지만, 안쪽 비교의 답이 그대로 올라오는지는 감싸는 자리에 따라 다르다. 함수 파라미터 자리는 방향이 반대로 검사되기 때문에 ((x: T) => void) extends ((x: true) => void)에 boolean을 넣으면 'Y'가 나온다.
naked type parameter는 공식 용어다. 조건부 타입을 도입한 PR과 TS 2.8 릴리스 노트에 나온다. 다만 현행 핸드북에서는 이 표현을 쓰지 않는다.
대괄호가 특별한 게 아니다
Tupled 같은 경우는 분배 법칙을 적용하지 않고 타입을 비교하고 싶을 때 쓰는 형태다. 관용적으로 [T] extends [U]를 쓰지만 대괄호에 특별한 기능이 있는 건 아니다. 위 표에서 보듯 T[]나 { v: T }로 감싸도 똑같이 분배가 꺼진다. 대괄호는 그중 가장 짧은 방법일 뿐이다.
never를 넣으면 어떻게 되나
type Wrap<T> = T extends unknown ? T[] : never;
type R1 = Wrap<never>; // never
type R2 = Wrap<string>; // string[]Wrap<'a' | 'b'> 멤버 2개 2회 실행 'a'[] | 'b'[]
Wrap<string> 멤버 1개 1회 실행 string[]
Wrap<never> 멤버 0개 0회 실행 결과 0개R1이 왜 never[]가 아니고 never인지 의문이 들 수 있다. 분배는 유니온 멤버마다 한 번씩 실행하고 그 결과들을 합치는 동작이라서, 실행 횟수가 멤버 수와 같다. never는 공집합이라 멤버가 0개이고, 따라서 T[]이 한 번도 평가되지 않는다. 결과가 0개인 합집합은 never다.
[].map(x => x * 2) // [] — 콜백이 호출조차 안 된다빈 배열에 map을 돌리면 콜백이 호출되지 않고 빈 배열이 나오는 것과 같다. never는 타입 레벨의 빈 배열이다.
type WrapNoDist<T> = [T] extends [unknown] ? T[] : never;
type R3 = WrapNoDist<never>; // never[]분배를 끄면 never[]가 나온다. 이때는 never를 쪼개지 않고 통째로 넘기니 never extends unknown을 한 번 검사하고 T[]을 평가한다. 결과가 갈리는 원인은 분배 하나뿐이다.