norswap · How TypeScript infers type variables
norswap
aka Nicolas Laurent
Home
About
Site Map
Github
RSS Feed
✉️ Blog as Newsletter
How TypeScript infers type variables
03 Jun 2026<br>This is the second article about what I learned about the TypeScript type system while writing a small library.<br>This one is about how TypeScript assigns a type to type variables defined in functions via inference.
You can also read the first article about how TypeScript distributes unions.
Type variable inference is a complex process. It often does what you expect or want, although when you're lost in the<br>type sauce, it's not always obvious what that is.
Sometimes however, it's a little bit bonkers:
class Foo {<br>foo(): Foo {<br>return new Foo()<br>declare const r: Foostring> | Foonumber><br>const x = r.foo() // typed as Foo<br>function foo(it: A & B): A & B { return it }<br>const x = foo(42) // typed as unknown<br>function foo(it: A & { x: number }): A { return it }<br>const x = foo({ x: 42, y: 42 }) // typed as { x: number, y: number }<br>class X {}<br>class A extends X { readonly kind = "A" }<br>class B extends X { readonly kind = "B" }
function fooextends X>(a: T, b: T): T { return a }<br>const x = foo(new A(), new B()) // T = A<br>// ^ TS2345: Argument of type B is not assignable to parameter of type A<br>const y = foo(new A(), new B()) // ok, also works with foo<br>In this article, I'll lay down the rules that will help you understand how TypeScript infers type variables.
Type variable inference is a complex topic and we will not cover all the intricacies. The information I'm presenting<br>here is probably missing details and might even be somewhat incorrect by virtue of being simplified. Nevertheless, it<br>matches all the observations I've made and seems to be congruent with TypeScript's implementation. In any case, this<br>understanding will serve you well whenever you need to reason about type variable inference.
Outline
Candidate Collection
Candidate Resolution
Type Variable Inference with Type Variables in Source Types
Summary
Outline
The process start with a series of source types, which are the types of the argument passed to the function, as well as<br>expected return type (e.g. if assigning to a variable declaration with an explicit type). These will be paired (1-1)<br>with a series of target types which are the types of the parameters of the function and its return type. The target<br>types can contain type variables.
The inference process then runs in two phases:
Candidate Collection — Walk the source and target types in parallel. Every time the walk reaches a bare type<br>parameter on the target side, record the corresponding source type as a candidate.
Candidate Resolution — Collapse the candidate list(s) into a single inferred type.
This is then followed by applying the regular type checking algorithm after injecting the resolved candidate into the<br>type parameters.
We'll detail these two phases in the two next sections.
Candidate Collection
As we said, every time a bare type parameter is reached, inference occur, otherwise we recurse.
Candidates are collected in two distinct lists: covariant ("output position") and contravariant ("input position"). See<br>this wikipedia article for more info on type variance.
Simple example:
function f(x: A, y: (a: B) => C) {}<br>f(true, (it: number) => "hello")<br>Infers A = boolean, B = number, C = string. B is a contravariant candidate, A and C are covariant candidates.
The "walk" we're referering to: when matching (it: number) => string to (a: B) => C, we're recursing inside both<br>structures as we're matching them to reach the bare type parameters. If a match is impossible, that part of the walk is<br>aborted during inference. If the source type cannot match the target type at all, you'll get a type error when regular<br>type checking runs.
This process also walks "into" type definitions.
type ArrayContainer = T[] | { array: T[] }<br>function f(x: ArrayContainer) {}<br>f([1, 2, 3])<br>Infers E = number. The type definition is expanded, so we're matching number[] against E[] | { array: E[] }.<br>The array side matches, we're collecting E = number as a candidate.
When a type-level conditional is encountered, candidates are collected on the right-hand side of conditionals.
type Pair = A extends string ? Map : [A, B]<br>function f(x: Pair) {}<br>f([1, 2])<br>f(new Map([["a", 1]]))<br>f(["a", "b"]) // TS2345: Argument of type string[] is not assignable to parameter of type Map<br>In this case, the first call collects A = number, B = number from the second branch, while the second call collects A = string, B = number from the first branch.
The third call collects A = string, B = string from the second branch. It causes an error at type-checking time<br>because ["a", "b"] does not satisfy Pair (when A = string, Pair = Map). Notice that<br>the candidates are collected from the second branch, but type checking walks into the first branch! Type variable<br>inference does not evaluate branches!
Note that object key and value type...