open atlas
↑ Back to track
TypeScript type system, deep TS · 01 · 02

Inference basics: how the checker fills in the types you didn't write

TypeScript infers most types so you don't annotate them. The rules — initializer typing, const-vs-let widening, contextual typing of callbacks, and best-common-type for arrays — are predictable once you see them. Annotate boundaries; infer locals.

TS Junior ◷ 13 min
Level
FoundationsJuniorMiddleSenior

You add : string to a local variable and your reviewer leaves a comment: “redundant — TS already knows.” Then on the next line you write const handler = items.map(x => x.toUpperCase()) and the callback parameter x is typed correctly with no annotation at all. TypeScript isn’t guessing; it’s running a small, deterministic inference algorithm. Learn its rules and you’ll annotate exactly where it pays off and nowhere else.

Inference from initializers

When you declare a variable with a value, TypeScript infers its type from the right-hand side. But the exact type depends on the binding keyword, because of widening:

let a = "hello";
//  ^? let a: string  — `let` is mutable, so the literal widens to its base type

const b = "hello";
//    ^? const b: "hello"  — `const` can never change, so the literal type is kept

This is the single most important inference rule to internalize. A let can be reassigned, so pinning it to the literal "hello" would be useless — TS widens to string. A const is immutable, so the precise literal type "hello" is both safe and more informative. Same for numbers and booleans: let n = 3 is number, const n = 3 is 3.

Object properties widen — even under const

Here’s the surprise that bites everyone. const on the binding doesn’t make the contents immutable, because object properties are mutable:

const config = { mode: "dark", retries: 3 };
//    ^? const config: { mode: string; retries: number }
//       NOT { mode: "dark"; retries: 3 } — properties widen
config.mode = "light"; // legal — the property is mutable, so its type is widened `string`

The const only freezes the reference config, not config.mode. Since you could reassign the property, TS widens it to string. When you actually need the literal types preserved — say, to pass into a function expecting a union — use a const assertion:

const config = { mode: "dark", retries: 3 } as const;
//    ^? const config: { readonly mode: "dark"; readonly retries: 3 }
config.mode = "light";
// Error: Cannot assign to 'mode' because it is a read-only property.

as const makes every property readonly and keeps its literal type. We go deep on this in the literal-types lesson; for now, know it’s the fix when widening throws away information you needed.

Contextual typing: callbacks get types for free

Inference also flows inward from the expected type. When a function expects a callback, the callback’s parameters are typed from that expectation — you don’t annotate them:

const names = ["ada", "alan", "grace"];
const upper = names.map((n) => n.toUpperCase());
//                       ^? (parameter) n: string
//    ^? const upper: string[]

n is string because Array<string>.map declares its callback as (value: string, ...) => U. This is contextual typing: the position the function literal sits in supplies the parameter types. It’s why DOM handlers work without annotation too:

button.addEventListener("click", (e) => {
  //                                ^? (parameter) e: MouseEvent
  console.log(e.clientX);
});

The string "click" selects the overload whose handler takes a MouseEvent, and that flows into e. Annotating e: MouseEvent here is redundant noise.

Why this works

Contextual typing only works when the expected type is visible at the point the function literal is written. Pull the callback out into a standalone const fn = (e) => ... and there’s no context anymore — e falls back to the implicit any (an error under noImplicitAny). That’s the trade: inline callbacks get free typing; extracted ones need an explicit parameter annotation or a typed alias.

Best-common-type for array literals

When you write an array literal, TypeScript computes a single element type — the best common type — that all elements satisfy:

const xs = [1, 2, 3];
//    ^? const xs: number[]

const mixed = [1, "two", 3];
//    ^? const mixed: (string | number)[]  — union of the element types

If the elements have no common supertype already in scope, TS forms a union of them. Note that const on the binding does not keep [1,2,3] as a tuple [number, number, number] — it’s still number[]; only as const produces a readonly [1, 2, 3] tuple (covered in the literal-types lesson).

Return types are inferred too

You almost never annotate a function’s return type for internal helpers — TS infers it from the return statements:

function parsePort(raw: string) {
  const n = Number(raw);
  return Number.isInteger(n) ? n : null;
}
//       ^? function parsePort(raw: string): number | null

The inferred return is number | null from the two branches. This is great for locals — but a sharp tool at API boundaries.

When to annotate vs let it infer

This is the question every TypeScript codebase eventually forces: over-annotate and you’re fighting the checker; under-annotate and you lose the contract. Here’s the rule professional codebases converge on:

SituationAnnotate?Why
Local variable with initializerNoInference is accurate and stays in sync with the value
Inline callback parameterNoContextual typing supplies it
Exported function return typeYesLocks the public contract; stops an internal change silently widening the API
Function parametersYesParameters are an input boundary — inference can’t see callers
Empty let x; then assigned laterYesBare let infers any; annotate to keep it checked

The principle: annotate boundaries, infer interiors. Explicit return types on exported functions are the highest-value annotations — they make the contract intentional and catch accidental widening when you refactor the body.

Why this works

Where inference hurts, with numbers. Inference is free where it’s local, but at module boundaries it has a real, measurable cost. When an exported function has no return annotation, tsc must re-derive that return type for every file that imports it, and complex inferred returns (deep generics, conditional types, long unions) are the classic driver of slow builds. On a ~900-file React + tRPC repo (TS 5.5), one over-clever helper whose return type inferred to a 30-member conditional-mapped type pushed tsc --noEmit from 9.4s to 16.1s; --extendedDiagnostics showed “Check time” nearly doubled and instantiation count jumped ~3.4M against the 100k-per-check / 5M-relation ceilings, with IntelliSense hover on the call sites lagging ~600ms+ as the editor recomputed the type. Pinning a hand-written return type cut it back to ~9.6s. The senior tradeoff: at internal call sites, let it infer — annotations there are noise that drifts out of sync; at exported boundaries, pay the one annotation — you trade three keystrokes for a stable public contract, faster incremental checks, and snappier editor latency for every consumer.

Quiz

What is the inferred type of `mode` in `const config = { mode: 'dark' }` (no `as const`)?

Complete the analogy

A callback parameter that gets its type from the function it's passed into — without any annotation — is being typed by ____ typing.

Recall before you leave
  1. 01
    Explain const-vs-let widening, and why object properties widen even when the binding is const.
  2. 02
    What is contextual typing, where does it apply, and when does it stop working?
  3. 03
    What is the practical annotation policy — what should you annotate and what should you leave to inference, and why?
Recap

Inference is why TypeScript stays out of your way: shapes you learned to reason about structurally in the last lesson are mostly filled in for you, with widening and contextual typing doing the heavy lifting. The widening rules you just saw — const keeping "a", let widening to string, properties widening, as const as the escape hatch — are exactly the machinery the next lesson on literal types builds on. But first we detour through the three special types — any, unknown, and never — because knowing how inference and assignability behave at the top and bottom of the type lattice is what keeps any from quietly poisoning everything inference just gave you. Now when you see a reviewer comment “redundant annotation”, check whether inference already covers that slot — and when you see a slow tsc, check whether an exported function is missing its return type.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.