Inference & infer
Where inference gets its answers: inference sites
Section titled “Where inference gets its answers: inference sites”When you call a generic function without type arguments, TypeScript looks at every place the type parameter appears in the parameters — each is an inference site — and computes a type that fits them all.
function pair<A, B>(a: A, b: B): [A, B] { return [a, b];}
const p = pair(1, "hi");// ^? [number, string] — A from the first arg, B from the secondWhen a parameter appears more than once, TypeScript reconciles the candidates — but “reconciles” does not mean “unions them into a best common type.” Each argument is checked against the same T, one candidate at a time:
function both<T>(a: T, b: T): T { return a; }
const x = both(1, 2);// ^? 1 | 2 — two literal candidates for the same T, kept as a union// @expect-error TS2345function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two"); // the first argument fixes T = 1, so "two" doesn't fitThe first argument becomes the working candidate for T; the second is then checked against that candidate, not merged into a wider common type — so 1 and "two" don’t reconcile into number | string, they conflict. To actually get a union out of both, name it explicitly:
function both<T>(a: T, b: T): T { return a; }
const z = both<number | string>(1, "two");// ^? number | stringUnderstanding that inference reads argument positions explains most “why did it infer that?” moments — and why annotating a return type never changes what the arguments infer.
infer: extract a type from inside another type
Section titled “infer: extract a type from inside another type”Inside a conditional type, the infer keyword introduces a new type variable that TypeScript solves for you by pattern-matching the structure. It is how you pull a piece out of a bigger type.
The canonical example — get a function’s return type:
interface User { id: string; name: string }
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type A = MyReturnType<() => number>; // numbertype B = MyReturnType<(x: string) => User>; // Usertype C = MyReturnType<string>; // never (not a function)Read infer R as: “match this type against the pattern (...args) => R, and if it fits, bind R to whatever was in the return position.” This is essentially how the built-in ReturnType<T> is defined — the real one (lib.es5.d.ts) adds a constraint and a different fallback: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;. The constraint guarantees T is callable at all, and the false branch is any, not never — so passing a non-function type doesn’t poison whatever consumes the result the way a never would (a never would silently vanish from unions; any at least stays visible and complains loudly at first use).
The pattern generalizes: pull out any part
Section titled “The pattern generalizes: pull out any part”infer can capture any position in a structure — element types, promise values, argument types:
// element type of an arraytype ElementType<T> = T extends (infer E)[] ? E : T;type E1 = ElementType<number[]>; // numbertype E2 = ElementType<string>; // string (unchanged — not an array)
// the value a Promise resolves totype Awaited1<T> = T extends Promise<infer V> ? V : T;type V1 = Awaited1<Promise<string>>; // string
// the first parameter typetype FirstArg<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;type P1 = FirstArg<(id: number) => void>; // numberEach one is the same move: describe the shape you expect, put infer X where the interesting part lives, and TypeScript fills X in when the shape matches.
A pitfall: multiple infer in the same position
Section titled “A pitfall: multiple infer in the same position”When the same infer name appears in multiple spots, TypeScript’s behavior depends on position — it infers a union in covariant positions and an intersection in contravariant (parameter) positions. You rarely need this, but it explains occasional surprises:
type Merge<T> = T extends { a: infer U; b: infer U } ? U : never;type M = Merge<{ a: string; b: number }>; // string | number (union)The Conditional Types lesson puts this side by side with the contravariant (intersection) case and proves both with type equality checks.
NoInfer<T>: blocking an inference site (5.4)
Section titled “NoInfer<T>: blocking an inference site (5.4)”Every occurrence of a type parameter in a parameter list is an inference site — including ones you don’t want contributing to the result. A “default” or “fallback” parameter is the classic case:
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {}
createStreetLight(["red", "yellow", "green"], "blue");// ✅ compiles — C is inferred as "red" | "yellow" | "green" | "blue", widened by defaultColorThat’s rarely what you want: "blue" isn’t one of the real colors, but because defaultColor is also an inference site for C, it just widens the union to include it. Wrap the site you don’t want to contribute in the built-in NoInfer<T>:
// @expect-error TS2345function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {}
createStreetLight(["red", "yellow", "green"], "blue");NoInfer erases to T at the type level — it changes where the compiler looks for candidates, not the resulting type. With it, defaultColor is checked against the C inferred from colors alone, so "blue" correctly fails. (The Foundations module’s Type Inference lesson introduces this briefly; this is the full picture, now that generics and inference sites are in scope.)
Callback inference in object literals (6.0)
Section titled “Callback inference in object literals (6.0)”Contextual typing has a classic weak spot: when an object literal holds several callbacks and T must be inferred from one of them, TypeScript used to treat all of them as context-sensitive, defer them together, and give up — leaving the un-annotated ones unknown. Since 6.0, a function that never mentions this is no longer context-sensitive, so inference can pick T from one annotated callback and flow it into the others:
declare function callIt<T>(obj: { produce: (x: number) => T; consume: (y: T) => void;}): void;
callIt({ consume(y) { return y.toFixed(); }, // y: number — inferred from produce produce(x: number) { return x * 2; },});Before 6.0, y was unknown here unless annotated by hand. The rule of thumb is unchanged — annotate a parameter when inference genuinely can’t see it — it just needs to fire less often.
infer X extends constraints (4.7+)
Section titled “infer X extends constraints (4.7+)”An inferred type variable can carry its own constraint, filtering what it’s allowed to bind to instead of accepting whatever the pattern matches:
type FirstIfString<T> = T extends [infer F extends string, ...unknown[]] ? F : "not a string";
type A = FirstIfString<["a", 1, 2]>;// ^? "a"type B = FirstIfString<[1, 2, 3]>;// ^? "not a string" — the first element didn't satisfy `extends string`Without the constraint, infer F alone would bind F to whatever sits in that position — 1 included — and you’d need a second, separate conditional to check whether F extends string. infer F extends string folds that check into the pattern match itself: if the first element doesn’t satisfy the constraint, the whole pattern fails to match, and you fall straight to the false branch.
infer inside a template literal type
Section titled “infer inside a template literal type”infer works inside a template literal pattern exactly the way it works inside a function-shape or array pattern — bind a variable to whatever substring matches a gap in the template:
type ParseRoute<S> = S extends `/users/${infer Id}/posts/${infer PostId}` ? { id: Id; postId: PostId } : never;
type R = ParseRoute<"/users/42/posts/7">;// ^? { id: "42"; postId: "7" }This is how a surprising amount of route-typing, URL-parsing, and CSS-in-TS tooling works: the string literal type itself is the data structure, and infer inside a template pattern is the mechanism for destructuring it.
Return-type inference from context
Section titled “Return-type inference from context”An inference site isn’t only an argument position — the type a value is being assigned into can also seed a type parameter, when there’s no argument to infer it from otherwise:
function make<T>(): T[] { return []; }
const arr: string[] = make();// ^? string[] — T inferred as string from the assignment's contextual type, not from any argumentWith no argument at all, make() has to get T from somewhere. TypeScript looks at what the result is being used as — here, the annotation on arr — and uses that as an inference site of last resort. Without even that contextual type to fall back on, T has genuinely nothing to infer from, and lands on unknown — the same “nothing to infer from” outcome the Generic Classes lesson showed for new Stack():
function make<T>(): T[] { return []; }
const loose = make();// ^? unknown[] — no argument, no contextual type — nothing to infer T fromInference priority: an argument always beats the contextual type
Section titled “Inference priority: an argument always beats the contextual type”When both an argument and a contextual (expected) type are present, the argument wins — the contextual type only ever fills a gap, it never overrides a candidate inference already has:
function wrap<T>(value: T): T[] { return [value]; }
const direct = wrap(1);// ^? number[] — T comes from the argument: 1
const strict: (string | number)[] = wrap(1);// ^? (string | number)[] as far as `strict`'s own annotation is concerned,// but wrap's inferred T is still number — the annotation widens the// *variable*, it does not change what T was inferred as inside wrapwrap(1) infers T as number both times — the annotation on strict only takes effect after wrap has already returned a number[], widening how that returned array is subsequently treated. It never reaches backward into wrap’s own inference and asks for a different T.
Debugging recipe: “why did it infer string, not the literal?”
Section titled “Debugging recipe: “why did it infer string, not the literal?””A common confusion: a generic that should preserve a literal type instead returns the widened one, and the generic function itself gets blamed. Usually the literal was already gone before the generic ever saw it:
function identity<T>(x: T): T { return x; }
let mode = "dark"; // let widens the initializer to string immediatelyconst r1 = identity(mode);// ^? string — too late: mode was already widened to string at its own declarationThe tell: as const on the call site doesn’t fix it, and the reason why it doesn’t is itself diagnostic — as const only accepts a literal expression, not a reference to a mutable variable:
// @expect-error TS1355function identity<T>(x: T): T { return x; }let mode = "dark";const r1b = identity(mode as const);If mode as const doesn’t even compile, that’s the compiler confirming there’s no literal left at that point to preserve — mode is fully and finally string from the moment it was declared. Two real fixes, and one common non-fix:
function identity<T>(x: T): T { return x; }
// fix 1: declare with const, so the literal is never widened away in the first placeconst literalMode = "dark";const r2 = identity(literalMode);// ^? "dark"
// fix 2: keep the let, but pass a fresh literal expression through `as const`let mode2 = "dark";const r3 = identity("dark" as const);// ^? "dark"// @expect-error TS2345// non-fix: naming the type argument explicitly does NOT rescue an// already-widened source — mode2 is `string`, and `string` isn't// assignable to the narrower `"dark"` this now demandsfunction identity<T>(x: T): T { return x; }let mode2 = "dark";const r4 = identity<"dark">(mode2);The recipe: when a generic “loses” a literal, look upstream of the call, not at the generic’s signature. Find where the value was declared, and check whether it was let (widens immediately) or a mutable array/object literal (widens its elements/properties) before the value ever reached the generic call. const T (from the previous lesson) and as const both only preserve a literal that’s still visible at the moment they run — neither can retroactively recover one that’s already gone.
infer destructuring a route out of a string literal type
type ParseRoute<S> = S extends `/users/${infer Id}/posts/${infer PostId}` ? { id: Id; postId: PostId } : never;
type R = ParseRoute<"/users/42/posts/7">;// ^? { id: "42"; postId: "7" }
// try changing the literal type below and re-running Check typestype Bad = ParseRoute<"/users/42">;// ^? never -- the pattern doesn't match at all Other 5.9 refinements
Section titled “Other 5.9 refinements”- Tighter inference. A fix for type variables “leaking” out of inference means some calls that previously accepted a wrong
Tnow error. If a call stops compiling after an upgrade, the intended fix is an explicit type argument:f<Expected>(...). This is a behavior change, not something you opt into or out of — there is no flag namedstrictInferencein TypeScript 7.0.2 (checked againsttsc --help --all); the tightened inference simply ships as part of ordinary inference. - Faster instantiation. Intermediate instantiations are cached, which is why heavy generic libraries (Zod, tRPC) hit “excessively deep” errors far less often.
Mental Model
Section titled “Mental Model”Every “surprising” inference result in this lesson reduces to the same read: find every position T appears in, order them by the priority above, and ask what the first one that has an answer actually offers — a widened type, because the source already widened before arriving, or a literal, because it hadn’t yet.
Trade-offs
Section titled “Trade-offs”| Tool | Benefit | Cost |
|---|---|---|
| Letting inference run (no explicit type argument) | Less to write; stays correct as call sites change | Can silently pick an unexpected candidate (a literal, unknown, a wide union) with no visual cue at the call site |
Explicit type argument (f<T>(...)) | Removes all ambiguity about what T is | Must be kept in sync by hand if the function’s real constraint changes |
infer X extends C | Folds a shape check into the pattern match itself — one conditional instead of two | Only available for infer sites; doesn’t help outside a conditional type |
| Contextual (expected-type) inference | Lets a generic call at a “leaf” position (no args) still infer something useful | Silently falls back to unknown the moment there’s no contextual type either — easy to miss |
Common Misconceptions
Section titled “Common Misconceptions”- Annotating a call’s return type changes what a generic function’s type parameters get inferred as. It doesn’t reach backward into the call — only argument positions and, failing that, the contextual type at the call site itself feed inference; an annotation on the variable the result gets widened into afterward changes nothing upstream.
as constat a call site always rescues a widened value.as constonly accepts a literal expression. A reference to aletvariable that already widened at its own declaration fails to compile asas constat all (TS1355) — that failure is itself proof the literal is already gone, not a case to work around with a cast.- Explicitly naming the type argument (
f<"dark">(x)) fixes an already-widened value. It changes what’s required, not what the argument actually is — ifxisstring, demandingTbe"dark"just produces a new, unrelated error (TS2345) instead of recovering the literal. - A conditional type with no matching
infersite just leaves the variable unset. It doesn’t — if the overall pattern doesn’t match at all, the whole conditional takes thefalsebranch, and theinfervariable never binds to anything, whether or not other parts of the pattern “look like” they matched.
Reading AI-generated code
Section titled “Reading AI-generated code”Long inferred types in a real error message are common once generics and object literals combine, and reading them is a skill, not a scanning problem:
error TS2322: Type '{ id: number; name: string; email: string; roles: string[]; active: boolean; }' is not assignable to type '{ id: string; }'. Types of property 'id' are incompatible. Type 'number' is not assignable to type 'string'.This came from identity({ id: 1, name: "Ada", email: "[email protected]", roles: ["admin", "user"], active: true }) assigned to a variable typed { id: string }. The compiler is showing you, verbatim, exactly what T got inferred as from the object literal argument — read it property by property like any other object type, don’t be intimidated by its length. The actual mismatch is buried in the second line: id: number vs. the target’s id: string. Everything before that is the compiler faithfully reporting the shape it settled on, not part of the problem.
When reviewing generated code, this pattern is worth watching for the opposite direction too: a generic call whose inferred type is suspiciously large or a suspiciously wide union is a sign that some upstream literal widened earlier than intended, or that an inference site the author didn’t expect (a default parameter, a fallback value) contributed a candidate it shouldn’t have — exactly the shape NoInfer<T> exists to fix.
🐛 Spot the bug
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {return { colors, defaultColor };}
const config = createStreetLight(["red", "yellow", "green"], "off");function isValidColor(c: string): c is "red" | "yellow" | "green" {return config.colors.includes(c as "red" | "yellow" | "green");}Show the answer
This compiles with zero errors, but config.defaultColor is not actually one of the three real colors. defaultColor?: C is an inference site for C just like colors: C[] is, so passing "off" doesn’t get rejected — it widens C to "red" | "yellow" | "green" | "off", and config.colors is inferred as that same widened union, not just the three real colors. isValidColor’s type predicate return type still only claims "red" | "yellow" | "green", but nothing checks that claim against the now-widened C — the fix from the NoInfer section of this lesson applies directly: annotate the fallback parameter as defaultColor?: NoInfer<C>, so it’s checked against the C inferred from colors alone instead of contributing its own candidate.