Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~24 min· topic 2 of 6

Topic 16.2

Inference in Depth

In one line

Inference is the checker working out types you did not write. It collects candidates from every argument, uses the surrounding code as a hint (contextual typing), can also read the expected return type, and widens or freezes literals depending on the context.

Think of it like this

A detective has several witnesses. Each gives a description of the same person. If they all agree, the case is closed. If one says "tall" and another says "tall, with a red coat", the detective trusts the more specific description. If two witnesses flatly disagree, the detective picks one and complains about the other. Inference works the same way: every place a type parameter appears gives a candidate, and the checker has to settle on one.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Type parameter
A blank in a generic function (like T) that is filled in by a type at each call.
Inference candidate
One guess for a type parameter, taken from one place where it appears in the call.
Covariant candidate
A candidate taken from a value you pass in. The result may be a wider type than the candidate.
Contravariant candidate
A candidate taken from a function parameter that consumes the type.
Contextual typing
Giving an unannotated expression its type from the place it is written, such as a callback parameter from the function it is passed to.
Return-type inference
Using the expected type of the result as a hint for a type parameter.
const context
A place (as const, or a const type parameter) where literals keep exact values and become readonly.
NoInfer
A marker that stops a position from adding candidates for a type parameter.

Step by step

01Every use of T votes

For a call, the checker visits each argument. Where the parameter's type mentions T, it reads the argument's type and records it as a candidate.

One candidate: easy. Several equal candidates: easy. Different candidates: it tries to merge them (objects become a union), and if they can't be merged it keeps the first and reports the others against it.

candidates.tswhole filets
function pair<T>(a: T, b: T): T[] {
  return [a, b];
}
pair(1, 2);                 // T = number
pair<string | number>("a", 1); // you decide
pair("a", 1);               // error: 1 is not a string

02Two directions: values and consumers

A value you pass makes a covariant candidate. A callback that takes T makes a contravariant one. If the value fits the callback, the value wins (mix("a", (x: string) => ...) gives "a"). If it does not, the callback wins and the value is reported.

03Context gives types to code that has none

An arrow function assigned to a variable of a function type, or passed to a parameter of a function type, takes its parameter types from that type. You write less, and the callback is still fully checked.

Take the context away and the parameter is any under the hood; strict mode asks you to write the type.

Context gives types to code that has nonediagram
Rendering diagram…

04The expected result is a fallback hint

make() with no arguments has nothing to learn from, so T is unknown. Writing const b: string[] = make() gives the checker the expected type, and T becomes string.

05Freezing literals and refusing a position

as const and const T keep the exact values and make the whole structure readonly. NoInfer<T> is the opposite tool: it removes one position from the vote, so a stray second argument cannot change the answer.

Try it yourself

  1. 1

    Make the candidates disagree

    In the first example, change pair(1, 2) to pair(1, 2n). Predict the error and which argument it points at before you look.

  2. 2

    Give the callback a context

    In the third example, change const bag = { onTap: (e) => e } to const bag: { onTap: (e: number) => number } = { onTap: (e) => e };. The error disappears. Hover e with a // ^? query to see where its type came from.

  3. 3

    Remove NoInfer

    In the last example, change NoInfer<T> to T. Predict what g becomes (the // ^? query shows it) and whether the create("a", 1) line is still an error.

Code & diagrams

Candidates: agree, merge, or complain

Equal candidates give one type. Different objects are merged into a union with the missing keys marked optional never. Incompatible primitives are not merged: the first wins and the second is the error, unless you spell the type argument yourself.

Console

(nothing was printed)

Expected console output

[ 'a', 1 ] [ null, 1 ]
Covariant and contravariant candidates

With only consumers, the most specific parameter type wins in both orders. In r3 the value "a" fits the callback, so it is the answer. In r4 the value is a plain string, which does not fit a callback that wants "a": the callback's type wins and the argument is the error.

Console

(nothing was printed)

Expected console output

a
Contextual typing and return-type inference

The expected type string[] turns T from unknown into string. The callbacks in map, in Handler and in Record all take their parameter types from the context. The plain object bag has no context, so e has no type and strict mode complains.

Console

(nothing was printed)

Expected console output

click 3
8
const contexts and NoInfer TypeScript 5.4+

as const and a const type parameter keep exact values and make everything readonly. Without const, plain() widens to [number, string, boolean]. In create, NoInfer stops the second argument from voting, so T stays "a" and 1 is rejected instead of widening T.

Console

(nothing was printed)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Take the context away

In the third example, remove the annotation: write const handlers = { instead of const handlers: Record<string, (n: number) => string> = {.

terminal
$ Types panel
── what you'll see ──
index.ts(17,11): error TS7006: Parameter 'e' implicitly has an 'any' type.
index.ts(21,12): error TS7006: Parameter 'n' implicitly has an 'any' type.
index.ts(21,12): (parameter) n: any

Break #2

Let the second argument vote

In the last example, change fallback: NoInfer<T> to fallback: T.

terminal
$ Types panel
── what you'll see ──
index.ts(20,7): const g: "a" | "b"

Myth vs fact

Myth

TypeScript infers the union of all arguments, so pair("a", 1) is (string | number)[].

Fact

It only merges candidates it can (objects, for example). For unrelated primitives it keeps the first and reports the rest. Write the type argument if you want the union.

Myth

Inference only goes from the arguments to the result.

Fact

It also flows from the expected type into the call (return-type inference) and from the place a callback is written into its parameters (contextual typing).

Myth

as const and const T are the same as readonly.

Fact

They freeze the whole value: exact literals, readonly tuples and readonly properties, at every depth. A plain readonly annotation protects only one level.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Arguments that are context-sensitive (an arrow function with an unannotated parameter, such as (x) => ...) are set aside, the other arguments are inferred first, and then the context-sensitive ones are checked left to right with whatever has been learned so far. So a callback can use a type learned from a plain argument wherever that argument sits: with declare function m<T, U>(f: (x: T) => U, arr: T[]): U[], the call m((x) => x.toFixed(), [1, 2]) gives x: number even though the array comes second. The order only matters between two context-sensitive callbacks. With run<T, U>(make: (seed: number) => T, use: (t: T) => U) the call run((seed) => ({ n: seed }), (t) => t.n) returns number; swap the two parameters in the declaration and the same two callbacks fail with TS18046: 't' is of type 'unknown', because use is checked before make has told the checker what T is. When you design an API, put the callback that produces a type before the callback that consumes it. (Both compilers behave the same; checked on 6.0.3 and 7.0.2.)

  • ▸

    A const type parameter only has an effect on arguments written as literals at the call. A variable declared elsewhere has already been widened by the time it is passed: with function ct<const T>(x: T): T, const arr = [1, 2]; ct(arr) is number[], while ct([1, 2]) is readonly [1, 2]. So wrap the literal in the call or declare the variable with as const. The constraint matters too: with a mutable array constraint (<const T extends unknown[]>) the call ctm([1, "a"]) gives the mutable tuple [1, "a"], while <const T extends readonly unknown[]> gives readonly [1, "a"].

  • ▸

    When inference gives a result you don't want, the three levers in order of preference are: change the signature so positions don't compete (NoInfer, or a second type parameter), add a constraint, or let callers write the type argument. Casting inside the call is the last resort.

Remember this

  1. 1

    The problem this solves: Generic code would be tiresome if you had to write the type argument at every call, so the checker works it out for you from the arguments and from the code around the call. Knowing exactly how it decides lets you predict the answer, and fix it when the answer is not the one you wanted. (Recall from Topic 7.2: a generic function has a type parameter that the checker fills in at each call.) A type parameter such as T in function pair<T>(a: T, b: T) is a blank the checker fills in at each call. It looks at every argument whose type mentions T; each one is an inference candidate. With pair(1, 2) both candidates are number, so T is number. With pair({ a: 1 }, { b: 2 }) the object types differ, and the checker builds a union of them. With pair("a", 1) it picks the first candidate, string, and then reports the second argument as the error. It does not invent string | number for you; to get that you write the type argument yourself: pair<string | number>("a", 1).

  2. 2

    Candidates come in two directions (Recall from Topic 7.7: covariant means "goes in the same direction", contravariant means "goes the opposite way", as with function parameters). A covariant candidate comes from a value you pass ("here is a "a""). A contravariant candidate comes from a function parameter that consumes T ("this callback accepts a string"). When both kinds exist, the checker prefers the covariant one if it fits the callbacks, otherwise it uses the contravariant one and the covariant argument becomes the error. When the value fits some callbacks but not others, the value still wins and the callbacks that do not accept it are the errors. The second example shows both outcomes with real output.

  3. 3

    Contextual typing (Recall from Topic 2.4: a callback's parameter types often come from the function it is passed to) runs the other way: instead of the code telling the checker the type, the place where the code is written does. In const h: Handler = (e) => ... nobody wrote a type for e, but Handler says what the parameter is, so e gets it. The same happens for callbacks passed to map, for properties of an object literal assigned to a typed variable, and for entries of a Record. Without any context (an arrow function stored in a plain object), a parameter has no type to take, and strict mode reports an implicit any (TS7006).

  4. 4

    Return-type inference lets the expected result type help. For function make<T>(): T[], nothing in the arguments mentions T, so const a = make() has no candidate and T falls back to unknown. With a target, const b: string[] = make(), the checker uses the annotation as a (low-priority) candidate and picks string. Candidates from arguments always beat candidates from the return position; the return position is the fallback.

  5. 5

    Literal widening during inference decides whether you get "a" or string. When a type parameter is only constrained loosely, a literal argument is widened (pair("a", "b") gives string[]). The literal is kept in two situations: when the constraint includes a primitive (T extends string gives ("a" | "b")[]), and when T is the whole return type (function id<T>(x: T): T gives id("a") the type "a"). Recall from Topic 7.6 that as const and a const type parameter (TypeScript 5.0) freeze the whole value, including nested arrays and objects, as readonly literals.

  6. 6

    NoInfer<T> (TypeScript 5.4, first met in Topic 7.6) tells the checker "do not use this position as a candidate; check it against what the other positions decided". In create(value: T, fallback: NoInfer<T>), only value teaches T; fallback must then fit. That removes the surprise where a second argument widens the type and hides a mistake.

Explain it without notes

01

How does TypeScript infer a type parameter when several arguments mention it?

02

What is contextual typing, and when does it fail?

03

How can the expected return type affect inference?

04

What does a const type parameter do, and how is it different from as const at the call?

05

When would you use NoInfer?

Practice

01

Write function firstOf<T>(xs: T[], fallback: NoInfer<T>): T and call it so that T is string. Show that firstOf(["a"], 1) is an error.

02

Make this call infer a tuple [number, string] without writing as const at the call site. ``ts function pairUp<T extends readonly unknown[]>(...xs: T): T { return xs; } const p = pairUp(1, "a");

Trade-offs

  • ↔

    Leaning on inference keeps code short and lets the types follow the values, but a surprising inferred type can travel far before anyone notices. Annotate exported function return types so a change inside cannot silently change your public types.

  • ↔

    Clever signatures (const type parameters, NoInfer, several type parameters) give callers better results but make the declaration harder to read. Use them in library code, and keep application code plain.

Done when you can

  • I can say where each inference candidate comes from, and what happens when they disagree.

  • I can explain covariant and contravariant candidates with the mix example.

  • I can predict which callbacks get contextual types and which get TS7006.

  • I can explain how the expected type of the result fills a type parameter.

  • I can use as const or const T to freeze literals, and NoInfer to stop a position from voting.