TypeScript path · The compiler · Topic 3 of 11

Inference, widening and narrowing

Not started

TypeScript works out most types itself. It infers a variable's type from the value it starts with, a function's return type from its return statements, and a callback's parameter types from where the callback is passed. Then, as your code tests values with if and switch, it narrows those types further. Annotations are for the places inference can't see: function parameters, public APIs, and values whose type you want to pin down on purpose.

Why it matters: Most types in good TypeScript are never written down. Knowing how the checker infers and narrows them tells you where an annotation helps and where it only gets in the way.

  1. 1Inference

    The checker works out types from initial values, return statements and the context a function is passed into, so most local variables need no annotation.

  2. 2Widening

    A `let` or an object property holding "draft" is widened to `string`, because it could change later. `const` and `as const` keep the literal.

  3. 3Narrowing

    Inside an `if`, after a `typeof`, `in`, `instanceof`, equality or truthiness check, the checker knows the value's type more precisely. This is control-flow analysis.

1

How it works

The explanation, with examples you can run.

Inference and widening

let a = "draft" // stringconst b = "draft" // "draft"const c = { status: "draft" } // { status: string }const d = { status: "draft" } as const // { readonly status: "draft" }const e = [1, "x"] // (string | number)[]

The rule behind these: the checker infers the type the value could later hold. A let can be reassigned and an object property can be overwritten, so the literal "draft" is widened to string. A const binding can't change, so it keeps the literal type. as const says that nothing in the expression will change: properties become readonly and keep their literal types, and arrays become readonly tuples.

Contextual typing works in the other direction. In [1, 2, 3].map((n) => n * 2), nobody wrote that n is a number: the checker knows what map passes to its callback.

Narrowing

A union type says a value may be one of several things. Each check you write tells the checker which one it has, from that point on:

type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number } function describe(input: string | number | Shape | null) {  if (input === null) return "nothing" // equality  if (typeof input === "string") return input.toUpperCase() // typeof  if (typeof input === "number") return input.toFixed(2)  if ("r" in input) return `circle ${input.r}` // in  return `square ${input.side}` // only one possibility left}

None of this is magic: each check is ordinary JavaScript that runs, and the checker follows the control flow (returns, else branches, assignments) to know which checks have passed at each line. Narrowing can only work with what exists at runtime, which is why it uses typeof, in and property values rather than type names.

When a check is too complex to follow, write a type predicate:

type Item = { id: string; price: number } function isItem(x: unknown): x is Item {  return typeof x === "object" && x !== null && "id" in x && "price" in x}

The checker trusts the x is Item claim without verifying the body, so a careless predicate is just a cast in disguise. Since TypeScript 5.5 it can infer simple predicates by itself, for example in values.filter((v) => v !== undefined).

satisfies: check without widening

type Config = Record<string, string | number> const annotated: Config = { port: 8080, host: "localhost" }const checked = { port: 8080, host: "localhost" } satisfies Config checked.port.toFixed(0) // fine: still known to be a numberannotated.port // type is string | number, and the key may not exist at all

An annotation replaces the inferred type; satisfies only checks against it. Use it for configuration objects and lookup tables, where you want both the check and the precise keys and values.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.What is the inferred type of `status`?
    const draft = { status: "draft" }const status = draft.status
  2. 2.`v` is `string | number`. What is its type on the last line?
    function f(v: string | number) {  if (typeof v === "string") return v.length  return v}
  3. 3.What does `config satisfies Config` do that `const config: Config = ...` doesn't?

0 of 3 right so far. Get all three to finish this topic.