Inference Deep Dive
How the compiler decides a type: infer, contextual typing, inference sites and priority, widening and literal types, and diagnosing why inference produced something too wide.
How the compiler decides a type: infer, contextual typing, inference sites and priority, widening and literal types, and diagnosing why inference produced something too wide.
You write const config = { mode: "dark" } and pass it to
something expecting { mode: "dark" | "light" }. It fails,
because mode is string. You write the same value inline and
it works.
Inference is running constantly and mostly invisibly, and when it produces something you did not expect the reason is always one of a handful of rules. By the end of this lesson you will know those rules, know where the checker gives up, and be able to steer the result instead of asserting your way past it.
The rule behind the opening surprise:
const a = "dark"; // "dark" - a literal type
let b = "dark"; // string - widened
const c = { mode: "dark" };
// c is { mode: string } - the property is mutable, so widenedThe literal type is kept.
const on a primitive. Nothing can ever make it a different
string, so "dark" stays "dark".
Widened to the base type.
A let, or a property of an object. Since a later assignment
is legal, the checker picks a type that would still be
correct afterwards.
Three ways to stop it:
const c = { mode: "dark" } as const;
// { readonly mode: "dark" }
const c: { mode: "dark" | "light" } = { mode: "dark" };
// the annotation gives the checker a target
const c = { mode: "dark" satisfies Mode };as const is the usual answer. It makes everything readonly and
keeps every literal exact, all the way down through nested
objects and arrays.
The same rule explains array inference:
const a = [1, 2, 3]; // number[]
const b = [1, 2, 3] as const; // readonly [1, 2, 3]And why an empty array needs help:
const items = []; // never[] under strict
const items: Photo[] = []; // what you meantTwo directions, and knowing which is operating explains most confusion.
Bottom-up: the type comes from the value.
const photo = { name: "dawn.jpg", size: 1450 };
// { name: string; size: number }Top-down (contextual): the type comes from where the value is going.
photos.map((photo) => photo.name);
// ~~~~~ inferred from what map expectsThe callback parameter was never annotated because map knows
what it passes. That is contextual typing, and it is why
callbacks throughout these courses have been bare.
Context is why the same expression can infer differently:
const handler = (event) => { ... };
// ~~~~~ implicitly any - no context
button.addEventListener("click", (event) => { ... });
// ~~~~~ MouseEvent - contextWhen a parameter is implicitly any, the reason is almost
always that the function was written where nothing said what it
would receive.
For a generic call, the checker collects candidates from every place the parameter appears, then picks one.
Two candidates that differ produce their union. But not always — the checker prefers the first candidate when one is a supertype:
Here items supplies number and the checker takes it, then
checks fallback against it. Argument order affects which wins,
which is why a puzzling generic error often changes if you swap
two parameters.
Return-position-only parameters cannot be inferred at all:
Bad — an annotation that discards what was inferred.
Good — satisfies, which
checks without widening.
An annotation is a target: the value must fit, and the
result is the annotation. So annotating with a broad type throws
away the precise one the checker had — STATUSES becomes
string[], and the derived union becomes string.
satisfies verifies the value against a type and keeps the
inferred one. It is the right tool whenever you want the check
but not the widening, which is most configuration objects and
every constant lookup table.
Two more controls:
A const type parameter infers literals for callers, which is
how a library gives good inference without asking users for as const.
NoInfer — built in since 5.4 — stops a position contributing a
candidate, so the first argument decides and the second is
checked against it.
Four situations worth recognising.
Circular inference
A type that depends on itself through a function's return. Annotating the return type breaks the cycle.
Unions of functions
Calling a value whose type is a union of signatures often fails, because the checker must find one signature that accepts every case.
Deeply nested generics
Inference works outside-in, so a type parameter appearing only inside three levels of wrapper may never be resolved.
any contaminates it
A value flowing from any infers as any, and everything
derived from it does too — the contagion from the practice
course, seen from the inference side.
The first one, concretely:
handlers cannot be typed until a is, and a cannot be typed
until handlers is.
Three habits make a library pleasant to use.
Put the type parameter where the argument is, so callers never write type arguments:
T is inferred from schema, and the caller writes nothing.
Use const parameters for literal-heavy APIs, so users get
narrow types without as const.
Return the narrowest type you actually produce. A function
returning Photo | undefined where it never returns undefined
forces every caller to handle a case that cannot happen — and
one returning any disables checking for all of them.
You now know why a literal widens, which direction a type came
from, and how satisfies gives you a check without the loss.
That last one is the practical takeaway — most places people
reach for an annotation on a constant, satisfies is what they
wanted.
Next is Variance, Assignability, and Structural Typing, which answers the other half of "why is this an error": not where a type came from, but why one type is or is not assignable to another.
Before you move on, take a constant object in your code with an
annotation on it, and switch the annotation to satisfies. Then
hover over a property. In most cases the type just became more
specific — and anything derived from it did too.
WIDENING
const a = "dark" "dark" cannot change
let b = "dark" string
{ mode: "dark" } { mode: string } property is mutable
as const keeps every literal, all the way down
an annotation gives a target
satisfies checks WITHOUT widening
const items = [] never[] under strict - annotate it
TWO DIRECTIONS
bottom-up the type comes from the value
contextual the type comes from where the value is going
an implicit any parameter usually means no context was available
GENERIC INFERENCE
candidates collected from every occurrence; differing ones union
argument ORDER affects which wins - swapping two can fix an error
a parameter only in the return position cannot be inferred
STEERING
const X: string[] = [...] throws away the literals
const X = [...] as const keeps them
satisfies T checks and keeps the inferred type
<- for config objects and tables
function f<const T>(...) literals for the CALLER
NoInfer<T> stop a position contributing
WHERE IT GIVES UP
circular references through a return type - annotate to break it
unions of function signatures
type parameters buried in nested generics
any contaminates everything downstream
DEBUGGING
hover every intermediate from the source forward
the first too-broad value is the cause
IN YOUR OWN APIs
type parameter where the ARGUMENT is, so callers write nothing
const parameters for literal-heavy APIs
return the narrowest type you actually producefunction pair<T>(a: T, b: T): [T, T] { ... }
pair("a", "b"); // T = string
pair("a", 1); // T = string | numberfunction first<T>(items: T[], fallback: T): T { ... }
first([1, 2], "none");
// ~~~~~~ not assignable to numberfunction make<T>(): T[] { ... }
const a = make(); // unknown[]
const b = make<Photo>(); // explicit is requiredconst STATUSES: string[] = ["pending", "done", "failed"];
type Status = (typeof STATUSES)[number]; // string - useless
const config: Record<string, unknown> = {
retries: 3,
endpoint: "https://api.example.com",
};
config.retries.toFixed(); // error - unknownconst STATUSES = ["pending", "done", "failed"] as const;
type Status = (typeof STATUSES)[number]; // "pending" | "done" | "failed"
const config = {
retries: 3,
endpoint: "https://api.example.com",
} satisfies Record<string, string | number>;
config.retries.toFixed(); // fine - still numberfunction pick<const T>(options: readonly T[]): T { ... }
pick(["a", "b"]); // "a" | "b" - no as const neededtype NoInfer<T> = [T][T extends unknown ? 0 : never];
function fill<T>(items: T[], value: NoInfer<T>): void { ... }
fill([1, 2], "x"); // error - T comes only from itemsconst handlers = {
a: () => handlers.b(), // circular
b: () => 1,
};function parse<T>(schema: Schema<T>, input: unknown): T { ... }