TypeScript 타입변수 추론 진단
언제 — 함수 호출에서 T 가 엉뚱하게 정해졌을 때. 증상으로 찾아 들어간다.
| 증상 | 원인 | 절 |
|---|---|---|
이상하게 unknown 이 나온다 |
그 변수가 바운드·조건 부분·인자에 안 드러난 위치에만 있다 | §수집 제외, §우회 |
| 형제 타입인데 첫 인자로 고정됐다 | 접기의 “첫 멤버 유지” | §해소 |
| 추론은 됐는데 그 값으로 체크가 깨진다 | 조건부 가지를 평가하지 않는다 | §1.4 |
| union 인자에서만 실패한다 | 분배 후보가 재-union 되지 않는다 | §1.6, §해소 |
모델 — 추론은 딱 2단계다
source 타입(인자 + 기대 반환) ┐
├─▶ ① 수집: 나란히 walk, bare T 만나면 후보 기록
target 타입(매개변수 + 반환) ┘ ├─▶ 공변(출력) 후보 리스트
└─▶ 반변(입력) 후보 리스트
② 해소: 각 리스트를 하나로 접기 → T 주입
③ *그제서야* 일반 타입 체크
수집과 타입 체크는 별개 단계다. 이게 조건부 타입에서 폭발한다.
하나만 외운다면 — 후보는 오직 source 타입과 그 구조적 부분에서만 줍는다. extends 바운드·기본값·조건부의 조건 부분·상위 타입은 절대 후보가 안 된다. 기괴한 사례 대부분이 이 한 문장의 따름정리다.
수집 규칙
1.1 기본 — target 에서 벌거벗은 타입변수를 만나면 그 자리 source 를 후보로 기록, 아니면 구조 안으로 walk.
function f<A, B, C>(x: A, y: (a: B) => C) {}
f(true, (it: number) => "hello") // A = boolean, B = number, C = string
B 는 함수 매개변수 위치라 반변, A·C 는 공변. 후보는 두 리스트에 나뉘어 쌓인다.
1.2 타입 별칭 안으로도 walk 한다. type ArrayContainer<T> = T[] | { array: T[] } 에 number[] 를 매칭하면 배열 쪽이 맞아 E = number.
1.3 조건부는 RHS(결과 가지)에서 수집한다. 조건 부분(A extends string)은 후보를 만들지 않는다.
type Pair<A, B> = A extends string ? Map<A, B> : [A, B]
function f<A, B>(x: Pair<A, B>) {}
f([1, 2]) // A = number, B = number (두 번째 가지)
f(new Map([["a",1]])) // A = string, B = number (첫 번째 가지)
1.4 ★조건부 타입은 가지를 “평가”하지 않는다.
f(["a", "b"]) // TS2345: string[] is not assignable to Map<string, string>
후보는 두 번째 가지에서 수집돼 A = string, B = string 이 되는데, 체크는 A = string 이므로 Pair<string,string> = Map<string,string> 인 첫 번째 가지로 걸어간다. 수집이 걸은 가지 ≠ 체크가 걷는 가지.
1.5 infer T 는 새 타입변수를 추가한다. 조건 부분이라도 infer 만은 예외로 새 변수를 만든다. Awaited<Promise<Promise<number>>> 에서 첫 전개의 U = Promise<number> 와 재귀 전개의 U = number 는 별개 변수다.
1.6 union 은 분배된다. source 가 union 이고 target 이 bare T 가 아니면 각 가지에 대해 독립 재실행하고, 결과 후보들은 같은 리스트에 쌓이고 다시 union 되지 않는다.
1.7 source 의 intersection 은 원칙적으로 유지되지만, 객체 타입({ x: T })에 구조적으로 매칭해야 할 때만 병합된 객체 형태가 생성된다.
1.8 수집에서 제외되는 것 — 조건부의 조건 부분, extends 바운드, 기본값, source 의 상위 타입. Elephant<Savannah> 에서 후보가 될 수 있는 건 Elephant<Savannah> 와 Savannah 뿐이고 Animal·Location 은 절대 안 된다.
따름정리 — number 인자로 string 바인딩을 만들 방법은 없다. 필요하면 §우회.
target intersection 의 특이동작
모호하면 아무것도 기록하지 않는다 — 임의 선택조차 안 한다.
function foo<A, B>(it: A & B) {}
foo(42) // A = unknown, B = unknown (unknown & unknown = unknown)
타입변수 “peel off” 는 차집합이 아니라 포획 유용성을 최대화하는 휴리스틱이다.
function foo<A>(it: A & { x: number }) {}
foo({ x: 42, y: 42 }) // A = { x: number, y: number } ← 전체 객체
function bar<A>(it: A & number) {}
bar(42 as number & { brand: "USD" }) // A = { brand: "USD" }
function qux<A>(it: A & "a") {}
qux("a" as "a" & { brand: 1 }) // A = { brand: 1 }
A & {객체} 면 전체 객체를, A & 리터럴 이면 정밀 리터럴을 남기는 쪽으로 동작한다. intersection 순서는 source·target 모두 무관.
해소 규칙
철칙 — 해소 결과는 후보 리스트에 없는 타입으로 절대 안 정해진다. [Cat, Dog] → Dog(첫 멤버). 공통 상위인 Animal 도 Cat|Dog 도 아니다.
리스트별 접기 (공변 기준, 반변은 관계 반전)
- 후보가 전부 같은 base 의 리터럴이면 → union
- 아니면 다른 모두의 strict 상위인 후보를 찾으면 → 그것
- 없으면 느슨한 상위 체크로 left-reduce → 상위가 리스트에 없으면 첫 후보 반환
declare function f<T>(x: T, y: T): T
f(d, a) // Animal (Animal 이 Dog 의 strict 상위)
f(d, c) // Dog (형제 → 첫 멤버 유지)
f("a", 1) // "a" (다른 base → 첫 멤버 유지)
f("a","b") // "a"|"b" (같은 base 리터럴 → union)
익명 리터럴 객체/배열이 여럿이면(공변 한정) 접기 전에 union 한다. named 타입끼리는 안 된다 — 서로 다른 named 타입을 같은 T 에 맞추려던 건 아닐 거라는 의도 추정이다.
공변·반변이 둘 다 있으면 ① 기본은 반변이 이긴다 ② 반변 결과가 바운드를 못 지키면 공변으로 폴백 ③ 공변 결과가 반변 결과의 하위 타입이면 공변이 이긴다(더 정밀하므로).
function f<T>(x: T, sink: (t: T) => void) { sink(x) }
f(new Dog(), (_: Animal) => {}) // T = Dog (공변이 반변의 하위 → 공변 승)
빈 리스트·바운드 — 둘 다 비면 파라미터의 기본값, 없으면 unknown. 해소값이 바운드를 못 지키면 바운드로 교체.
기본값은 별칭이 아니라 “해소 대상 파라미터”에서 찾는다.
type Result<V, E = never> = { okay: V } | { error: E }
function f<V, E>(x: Result<V, E>) {}
f({ okay: 42 }) // E = unknown — never 가 아니다
Result 의 never 기본값은 Result<X> 처럼 인자를 생략할 때만 발동한다. 호출부에서 never 를 원하면 function f<V, E = never> 로 선언한다.
우선순위 비트 — 수집 시 후보마다 태그가 붙고, 더 높은 우선순위 후보가 오면 낮은 것들은 전부 지워진다. ReturnType(호출 문맥의 반환 타입 추론, 함수를 인자로 넘길 때), LiteralKeyof(리터럴을 keyof T 에 매칭), MappedTypeConstraint 셋은 “combination” 우선순위라 상위/하위 대신 union(공변)/intersection(반변) 으로 해소한다.
우회 패턴
제네릭 함수 안에서는 타입변수가 미확정이라 조건부를 평가할 수 없다. Exclude 로 특정 타입을 막고 싶으면 파라미터 위치에서 타입변수를 추론하는 형태만 동작한다.
function foo(x: Exclude<unknown, number>) {} // 파라미터가 unknown 으로 축약 → foo(42) 허용
function bar<X>(x: Exclude<X, number>) {} // X = number 추론 → Exclude<number,number> = never
function quz<X, Y extends Exclude<X, number>>(x: Y) {} // X 는 바운드에만 → 추론 불가 → unknown
bar(42) // 에러: 42 not assignable to never ← 이것만 제대로 막는다
넘겨 쓸 때는 타입을 그대로 재현한다 — function quuz<V>(x: Exclude<V, number>) { bar(x) } 는 조건을 평가 못 해도 두 x 가 구조적으로 동일해서 통과한다.
NoInfer<T> — 그 위치에서 T 의 후보를 수집하지 않는다. 다른 위치에서 정해진 T 를 그대로 강제할 때.
가장 짜증나는 상황 — 추론하고 싶은 타입변수가 인자에 드러나지 않을 때. extends 바운드는 후보를 안 만들기 때문이다.
function foo<K, V, T extends Map<K, V>>(map: T, key: K): [T, V] { return [map, map.get(key)!] }
foo(new Map([[42, "42"]]), 42) // [Map<number,string>, unknown] ← V = unknown
해법 둘, 트레이드오프가 있고 둘 다는 안 된다.
// 1) extractor 타입 — 캐스트 필요, union 지원
type GetV<T extends Map<unknown, unknown>> = T extends Map<unknown, infer V> ? V : never
function foo<K, V, T extends Map<K, V>>(map: T, key: K): [T, GetV<T>] {
return [map, map.get(key) as GetV<T>]
}
// 2) map: T & Map<K, V> — 캐스트 불필요, union 인자에서 깨짐
function foo<K, V, T extends Map<K, V>>(map: T & Map<K, V>, key: K): [T, V] { … }
함정 — A extends string ? { [key in A]: B } : … 형태의 매핑드 타입에서는 키/값 추론이 안 된다. [A, B] 같은 구조로 쓴다. 그리고 일부 IDE 는 NoInfer 를 못 뚫고 헛경고를 낸다.