How TypeScript infers type variables

This is the second article about what I learned about the TypeScript type system while writing a small library. 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 type sauce, it's not always obvious what that is.

Sometimes however, it's a little bit bonkers:

class Foo<A> {
    foo(): Foo<A> {
        return new Foo()
    }
}
declare const r: Foo<string> | Foo<number>
const x = r.foo() // typed as Foo<string>
function foo<A, B>(it: A & B): A & B { return it }
const x = foo(42) // typed as unknown
function foo<A>(it: A & { x: number }): A { return it }
const x = foo({ x: 42, y: 42 }) // typed as { x: number, y: number }
class X {}
class A extends X { readonly kind = "A" }
class B extends X { readonly kind = "B" }

function foo<T extends X>(a: T, b: T): T { return a }
const x = foo(new A(), new B()) // T = A
// ^ TS2345: Argument of type B is not assignable to parameter of type A
const y = foo<A|B>(new A(), new B()) // ok, also works with foo<X>

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 here is probably missing details and might even be somewhat incorrect by virtue of being simplified. Nevertheless, it matches all the observations I've made and seems to be congruent with TypeScript's implementation. In any case, this understanding will serve you well whenever you need to reason about type variable inference.

Outline

The process start with a series of source types, which are the types of the argument passed to the function, as well as expected return type (e.g. if assigning to a variable declaration with an explicit type). These will be paired (1-1) with a series of target types which are the types of the parameters of the function and its return type. The target types can contain type variables.

The inference process then runs in two phases:

  1. Candidate Collection — Walk the source and target types in parallel. Every time the walk reaches a bare type parameter on the target side, record the corresponding source type as a candidate.
  2. 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 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 this wikipedia article for more info on type variance.

Simple example:

function f<A, B, C>(x: A, y: (a: B) => C) {}
f(true, (it: number) => "hello")

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 structures as we're matching them to reach the bare type parameters. If a match is impossible, that part of the walk is aborted during inference. If the source type cannot match the target type at all, you'll get a type error when regular type checking runs.

This process also walks "into" type definitions.

type ArrayContainer<T> = T[] | { array: T[] }
function f<E>(x: ArrayContainer<E>) {}
f([1, 2, 3])

Infers E = number. The type definition is expanded, so we're matching number[] against E[] | { array: E[] }. 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, B> = A extends string ? Map<A, B> : [A, B]
function f<A, B>(x: Pair<A, B>) {}
f([1, 2])
f(new Map([["a", 1]]))
f(["a", "b"]) // TS2345: Argument of type string[] is not assignable to parameter of type Map<string, string>

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 because ["a", "b"] does not satisfy Pair<string, string> (when A = string, Pair<A, B> = Map<A, B>). Notice that the candidates are collected from the second branch, but type checking walks into the first branch! Type variable inference does not evaluate branches!

Note that object key and value type inference in conditionals does not work (I don't know why). So we couldn't have written type Pair<A, B> = A extends string ? { [key in A]: B } : [A, B].

Additionally infer T directives create new type variables that are added to the set of variables to be inferred.

A goood example is this simplified redefinition of the standard Awaited type (only for promises, not all thenables):

type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
type Foo = Awaited<Promise<Promise<number>>>

In this case, U is inferred to Promise<number> in the first expansion of Awaited, then U is inferred to number in the recursive expansion of Awaited (these two Us count as distinct type variables).

Let's now highlight some unintuitive aspects of this, as well as give some additional information on special cases.

What is not considered during candidate collection.

Inference distributes over unions, source intersection types sometimes merge.

Whenever the source type at a walk step is a union and the target type is not a bare type parameter (in which case it simply matches the union), the walk iterates over union branches and reruns inference against the target for each one independently. All resulting candidates land in the same list (they are not re-unioned).

declare function f<T>(x: Foo<T>): T
declare const u: Foo<Dog> | Foo<Cat>
f(u) // candidates: [Dog, Cat]

From my article on union distribution, also remember that type conditionals distribute if the thing before extends is a naked type variable (e.g. A extends { x: number } distributes, [A] extends [{x: number}] does not).

Source intersection types mostly remain intact, except when structurally matching to an object type (e.g. { x: T }) is required. In the case of structural matching, the intersected (merged) object shape is created (e.g. { x: string, y: number } & { x: "a" } yields { x: "a", y: number }).

Ambiguous target intersections do not record candidates

function foo<A, B>(it: A & B) {}
foo(42)

Both A and B infer to unknown, as TypeScript is unable to determine whether A or B should match number (but bizarrely does not choose arbitrarily).

Type variables in target intersections sometimes "peel off" and sometimes don't

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 baz<A>(it: A & string) {}
baz("a" as "a" & { brand: 1 }) // A = "a" & { brand: 1 }

function qux<A>(it: A & "a") {}
qux("a" as "a" & { brand: 1 }) // A = { brand: 1 }

We could expect A to infer to { y: number } in the foo call, and to { brand: 1 } in the baz call, but that is not the case. These are probably heuristics designed to maximize the utility of the captured type: in the first case, to capture the entire object type, and in the second case to capture the precise literal string.

Note that the order in which the intersection is specified (both in source and target types) never matters.

Important takeaway: type variables can only match against structural parts of the concrete type.

If we have a source type Elephant<Savannah> with Elephant extends Animal and Savannah extends Location, type variables inferred against this source type can only ever match Elephant<Savannah>, or Savannah. Supertypes are never considered for candidate collection, so Animal, Animal<Savannah>, Location, etc... will never end up in the candidate lists.

Another corollary: there is no way to have an argument of type number and produce a binding of a type variable to string. Surprisingly, that would actualy be quite convenient, consider:

type Indexed<K, V> = K extends string ? Map<K, V> : V[]
function f<K, V>(x: Indexed<K, V>) {}
f([1, 2])
f(new Map([["a", 1]]))

We'd love to infer K = number when the argument is an array! Of course we don't use the key type for anything. If we needed it, here's how we could get around it:

type Indexed<K, V> = K extends string ? Map<K, V> : V[]
type Key<K> = K extends string ? string : number
declare function f<K, V>(x: Indexed<K, V>): Key<K>
const x: number = f([1, 2])
const y: string = f(new Map([["a", 1]]))

Here's an alternative way:

type Indexed<K, V> = K extends string ? Map<K, V> : V[]
type Key<T> = T extends Map<infer K, any> ? K : number
declare function f<T, K, V>(x: T & Indexed<K, V>): Key<T>
const x: number = f([1, 2])
const y: string = f(new Map([["a", 1]]))

It's unnecessary here, but the pattern of saving the entire input type T (and how to do so it needs to appear in an inference position) ends up being useful quite often when doing advanced type alchemy.

In fact we use this trick in our article on union distribution to simulate overloads with generics.

Candidate Resolution

Candidate collection outputs two candidate lists: covariant and contravariant. Resolution reduces each list to a single type, then combines them.

The general process:

The "anonymous literal types" rule is weird. Below is an illustration. The motivation boils down to the fact that we probably didn't intend different named types to match the same type variable, while if we're using object or array literals, that might actually be our intent, and this lets us avoid writing extra type definition to clarify.

declare function f<T>(x: T, y: T): T
f({a: 1}, {b: 2}) // T = { a: number } | { b: number }

interface A { a: number }
interface B { b: number }
declare const a: A, b: B
f(a, b) // TS2345: Argument of type B is not assignable to parameter of type A

Regarding choosing the covariant type over the contravariant type when both are present and the covariant type is a subtype the contravariant type, this example will make it make sense:

function f<T>(x: T, sink: (t: T) => void) { sink(x) }
f(new Dog(), (_: Animal) => {}) // T = Dog
f(new Animal(), (_: Dog) => {}) // T = Dog
// ^ TS2345: Argument of type Animal is not assignable to parameter of type Dog

In the first call, everything make senses: a function accepting animals also accepts dogs (i.e. T is contravariant!), so everything type checks. Both T = Animal and T = Dog would have worked, but Dog is more precise. Imagine we want to return x from the function, inferring T = Dog does indeed give is a more precise return type.

In the second one, it's impossible to type check the call given the two arguments, no matter what we infer T to (Dog or Animal).

Collapsing Candidate Lists

Here's how the candidate lists are collapsed. This is the explanation for the covariant list, but the principle is the same for the contravariant list, just inverting the type relationship (look for subtypes instead of supertypes).

  1. If all candidates are literals of the same base type, union them.
  2. Otherwise scan the list looking for one candidate that is a strict supertype of all others. If found, return it.
  3. If not, try again using a looser supertype check (we won't get into the details, it's very edge-casey, like "does any subtype unknown?).
    • This is a left-reduce: start with the first type as the candidate, then replacing it with a supertype if one is encountered. Don't exit early if unrelated types are encountered.
    • As a result, if no candidate is a supertype of the first candidate, the first candidate is returned.

Crucially, resolution never settles on a type that is not on a list of candidates. So if your candidate list is [Cat, Dog], the type will resolve to Dog and not the common supertype Animal, nor Cat | Dog.

Examples

function f<T>(x: T, y: T) {}
f("a", "b")  // T = "a" | "b"

Two covariant candidates, both string literals of the same base. Same-base-literal candidates get unioned.

class Animal {}
class Dog extends Animal { readonly kind = "dog" }
declare const a: Animal, d: Dog, c: Cat
declare const ad: Dog[], ac: Cat[]

declare function f<T>(x: T, y: T): T
f(d, a)         // T = Animal       (Animal is strict supertype of Dog → found)
f(a, d)         // T = Animal       (same)
f(d, c)         // T = Dog          (siblings → first kept!)
f("a", 1)       // T = "a"          (different bases → first kept)
f(ad, ac)       // T = Dog[]        (sibling Array generics → first kept)
declare function f<T>(x: T, y: T): T
f(new Dog(), new Cat()) // T = Dog (not Animal!)

Priorities

Each candidate is tagged with priority bits at collection time, with lower numeric value = higher priority. When a candidate of higher priority arrives, candidates at lower priorities (higher numeric value) get wiped.

(This is a collection-time concern, but since the consequences of priorities are felt during candidate selection, I'm including it in this section.)

Here are the priority bits alongside where they apply:

MappedTypeConstraint, ReturnType, and LiteralKeyof are the "combination" priorities — if the candidate list is at this priority, the resolved type is obtained via union (covariant) or intersection (contravariant) instead of supertype/subtype (respectively).

Defaults are looked up on the type parameter being resolved, not on aliases referenced from the parameter type.

type Result<V, E = never> = { okay: V } | { error: E }
function f<V, E>(x: Result<V, E>) {}
f({ okay: 42 }) // E = unknown, NOT never

The walk into Result collects no candidates for E. Resolution of f's E then checks f's declared default — there isn't one — and falls back to unknown. The E = never default belongs to Result, and only kicks in when Result is referenced as Result<X> with the second argument omitted.

To get never at the call site, you need function f<V, E = never>(...) {}.

Type Variable Inference with Type Variables in Source Types

When inference is done in a generic context where type variables appear in the source type, one complication is introduced: type conditionals cannot be evaluated.

Type variable inference is always local, but locally in a generic function, type variables are uninstantiated, hence why evaluating conditionals is not possible.

Let's use the standard Exclude type, defined as type Exclude<T, U> = T extends U ? never : T as an intuition pump.

function foo(x: Exclude<unknown, number>) {}
function bar<X>(x: Exclude<X, number>) {}
function baz<X extends Exclude<unknown, number>>(x: X) {}
function quz<X, Y extends Exclude<X, number>>(x: Y) {}
foo(42) // allowed
bar(42) // TS2345: Argument of type 42 is not assignable to parameter of type never
baz(42) // allowed
quz(42) // allowed

In general, Exclude is meant to work with an union as its first type parameter. Remember unions distribute over type conditionals. But here's how it works (or doesn't) here.

In foo, the parameter type reduces to unknown (unknown doesn't extend number, so Exclude<unknown, number> = unknown) and so our intent to block numbers fails.

In bar, X is inferred to number, so we get Exclude<number, number> = never for the parameter type and the number argument is successfully blocked.

baz fails in exactly the same way as foo (the bound becomes X extends unknown).

In quz, there is no way to infer X at all! It only appears in the extends bound, which doesn't contribute an inference candidate, and so we end up with X = unknown.

Now let's introduce the generic context by calling bar (the correct function) from a generic function, here's the only way that will work:

function quuz<V>(x: Exclude<V, number>) { bar(x) }

You basically need to reproduce the type. Because inside quuz, V is unbound, the conditional in Exclude<V, number> cannot be evaluated. The only way this type-checks is because TypeScript does a structural match between the type of the x argument from quuz and the type of the x parameter from bar. Even though it can't evaluate the conditional, the types are structurally identical, and the call type checks.

Steering Inference With NoInfer<T>

TypeScript offers the built-in utility type NoInfer to block inference. Instead of using a type variable T, you write NoInfer<T>, the effect of which is that T will not collect candidates from that position.

Note a minor inconvenience of NoInfer: at the time of writing, some IDE tooling (at least IntelliJ) doesn't always see through NoInfer and might give you some spurious warnings related to it.

Summary

We learned about the complex algorithm TypeScript uses to infer function-level type variables when a function call is made. If we simplify a lot, here's what that algorithm looks like:

And note that intersection types behave a little strangely sometimes, but refer to the article for that.

Hopefully this helps you debug some unintuitive type variable inference behaviour! See you around, language nerd.