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(첫 멤버). 공통 상위인 AnimalCat|Dog 도 아니다.

리스트별 접기 (공변 기준, 반변은 관계 반전)

  1. 후보가 전부 같은 base 의 리터럴이면 → union
  2. 아니면 다른 모두의 strict 상위인 후보를 찾으면 → 그것
  3. 없으면 느슨한 상위 체크로 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 가 아니다

Resultnever 기본값은 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 를 못 뚫고 헛경고를 낸다.

#589raw