---
type: note
kind: Recipe
title: TypeScript 타입변수 추론 진단
description: T가 왜 그 타입으로 정해졌는지 역추적하는 절차. 수집·해소 2단계 모델과 우회 패턴.
tags: ['typescript', 'type-inference', 'generics', 'debug']
status: release
ctime: 2026-09-01
mtime: 2026-09-01
generated: { by: claude/opus-5, at: 2026-09-01T00:00:00Z }
sources:
  - id: norswap-inference
    resource: https://norswap.com/typescript-type-variable-inference/
    title: "norswap — How TypeScript infers type variables"
---

**언제** — 함수 호출에서 `T` 가 엉뚱하게 정해졌을 때. 증상으로 찾아 들어간다.

| 증상 | 원인 | 절 |
|---|---|---|
| 이상하게 `unknown` 이 나온다 | 그 변수가 바운드·조건 부분·인자에 안 드러난 위치에만 있다 | §수집 제외, §우회 |
| 형제 타입인데 첫 인자로 고정됐다 | 접기의 "첫 멤버 유지" | §해소 |
| 추론은 됐는데 그 값으로 체크가 깨진다 | 조건부 가지를 평가하지 않는다 | §1.4 |
| union 인자에서만 실패한다 | 분배 후보가 재-union 되지 않는다 | §1.6, §해소 |

---

## 모델 — 추론은 딱 2단계다

```
source 타입(인자 + 기대 반환)  ┐
                              ├─▶ ① 수집: 나란히 walk, bare T 만나면 후보 기록
target 타입(매개변수 + 반환)   ┘        ├─▶ 공변(출력) 후보 리스트
                                        └─▶ 반변(입력) 후보 리스트
                              ② 해소: 각 리스트를 하나로 접기 → T 주입
                              ③ *그제서야* 일반 타입 체크
```

**수집과 타입 체크는 별개 단계다.** 이게 조건부 타입에서 폭발한다.

**하나만 외운다면** — 후보는 오직 **source 타입과 그 구조적 부분에서만** 줍는다. `extends` 바운드·기본값·조건부의 *조건 부분*·상위 타입은 **절대 후보가 안 된다.** 기괴한 사례 대부분이 이 한 문장의 따름정리다.

---

## 수집 규칙

**1.1 기본** — target 에서 벌거벗은 타입변수를 만나면 그 자리 source 를 후보로 기록, 아니면 구조 안으로 walk.

```ts
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`)은 후보를 만들지 않는다.

```ts
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 ★조건부 타입은 가지를 "평가"하지 않는다.**

```ts
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 의 특이동작

**모호하면 아무것도 기록하지 않는다** — 임의 선택조차 안 한다.

```ts
function foo<A, B>(it: A & B) {}
foo(42)   // A = unknown, B = unknown  (unknown & unknown = unknown)
```

**타입변수 "peel off" 는 차집합이 아니라 포획 유용성을 최대화하는 휴리스틱이다.**

```ts
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` 도 아니다.

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

1. 후보가 전부 **같은 base 의 리터럴**이면 → union
2. 아니면 **다른 모두의 strict 상위**인 후보를 찾으면 → 그것
3. 없으면 느슨한 상위 체크로 left-reduce → 상위가 리스트에 없으면 **첫 후보 반환**

```ts
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` 에 맞추려던 건 아닐 거라는 의도 추정이다.

**공변·반변이 둘 다 있으면** ① 기본은 반변이 이긴다 ② 반변 결과가 바운드를 못 지키면 공변으로 폴백 ③ **공변 결과가 반변 결과의 하위 타입이면 공변이 이긴다**(더 정밀하므로).

```ts
function f<T>(x: T, sink: (t: T) => void) { sink(x) }
f(new Dog(), (_: Animal) => {})   // T = Dog  (공변이 반변의 하위 → 공변 승)
```

**빈 리스트·바운드** — 둘 다 비면 파라미터의 기본값, 없으면 `unknown`. 해소값이 바운드를 못 지키면 **바운드로 교체.**

**기본값은 별칭이 아니라 "해소 대상 파라미터"에서 찾는다.**

```ts
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` 로 특정 타입을 막고 싶으면 **파라미터 위치에서 타입변수를 추론하는 형태만** 동작한다.

```ts
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` 바운드는 후보를 안 만들기 때문이다.

```ts
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
```

해법 둘, 트레이드오프가 있고 **둘 다는 안 된다.**

```ts
// 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` 를 못 뚫고 헛경고를 낸다.
