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 allAn 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.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.