Seung Hun

2026년 8월 18일

TS 조건부 타입 — boolean extends true가 두 번 다른 답을 내는 이유

같은 비교인데 답이 다르다

typescript
type Direct = boolean extends true ? 'Y' : 'N';

type ViaParam<T> = T extends true ? 'Y' : 'N';
type Indirect = ViaParam<boolean>;

여기서 Direct'N'이다. 하지만 Indirect'Y' | 'N'이 나온다. 둘의 차이는 타입 파라미터를 거쳤는지 여부 하나뿐이다.

예상과 다른 결과

typescript
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;

이것은 (string | number)[]로 예상할 수 있는데 실제로는 string[] | number[]로 나온다.

extends는 if가 아니다

타입은 값의 집합이다

typescript
'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'              // false

extends는 단순히 if문이 아니고 부분집합인가를 확인하는 것이다.

typescript
X extends Y ? A : B

이런 문법이 있다고 했을 때 X가 Y의 부분집합이면 A 아니면 B이다. 그리고 unknownnever는 각각 전체집합, 공집합에 대응한다. 그래서 never extends string이 true다. 공집합에는 오른쪽에 없는 원소가 애초에 없어서 반례를 만들 수 없다.

방향이 뒤집히면 답도 뒤집힌다

typescript
declare let s: string;
declare let h: 'hello';

s = h;   // OK
h = s;   // 에러

extends는 대칭이 아니라서 방향을 바꾸면 답이 달라질 수 있다. 위 예시를 보면 'hello'string의 부분집합이라서 s = h는 문제 없지만 그 반대는 성립하지 않는다.

두 타입이 서로의 부분집합이면 양방향 모두 true다. string extends string이 그렇다.

객체는 속성이 많은 쪽이 부분집합

typescript
{ 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 }를 전부 포함한다. 제약이 하나 늘어날 때마다 집합은 좁아진다.

분배 법칙

규칙

typescript
X extends Y ? A : B

X가 타입 파라미터 그 자체이고 거기에 유니온(|) 타입이 오면 분배 법칙이 적용된다. "그 자체"라는 조건이 중요한데, 이건 다음 섹션에서 다룬다. 그리고 여기서 말하는 유니온은 멤버가 0개인 경우까지 포함한다. never가 그 경우이고, 마지막 섹션에서 따로 다룬다.

그래서 맨 처음에 나왔던 Indirect의 타입은 'Y' | 'N'이다. (참고로 booleantrue | false와 동치다.)

추적해 보기

typescript
type ViaParam<T> = T extends true ? 'Y' : 'N';
type Indirect = ViaParam<boolean>;
javascript
Indirect
= ViaParam<boolean>
= ViaParam<true | false>
= ViaParam<true> | ViaParam<false>                     쪼갠다
= (true extends true ? 'Y' : 'N')
  | (false extends true ? 'Y' : 'N')                   각각 평가한다
= 'Y' | 'N'                                            합친다
typescript
type Direct = boolean extends true ? 'Y' : 'N';
javascript
Direct
= (true | false) extends true ? 'Y' : 'N'
= 'N'

Indirect에서 파라미터로 들어온 boolean(= true | false)은 각각 ViaParam<true> | ViaParam<false>로 분배된다. 하지만 Direct는 검사 대상이 타입 파라미터가 아니기 때문에 분배되지 않고 그대로 사용된다.

언제 분배되고 언제 안 되나

naked type parameter

typescript
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를 넣으면 어떻게 되나

typescript
type Wrap<T> = T extends unknown ? T[] : never;

type R1 = Wrap<never>;    // never
type R2 = Wrap<string>;   // string[]
javascript
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다.

javascript
[].map(x => x * 2)   // []  — 콜백이 호출조차 안 된다

빈 배열에 map을 돌리면 콜백이 호출되지 않고 빈 배열이 나오는 것과 같다. never는 타입 레벨의 빈 배열이다.

typescript
type WrapNoDist<T> = [T] extends [unknown] ? T[] : never;
type R3 = WrapNoDist<never>;   // never[]

분배를 끄면 never[]가 나온다. 이때는 never를 쪼개지 않고 통째로 넘기니 never extends unknown을 한 번 검사하고 T[]을 평가한다. 결과가 갈리는 원인은 분배 하나뿐이다.