Skip to content

3.7.3 — Narrowing & Control-Flow Analysis

3.7.2 argued that real domains are unions — loading or loaded, string or number, success or failure. But a union is only useful if you can safely get back to the specific case. That's narrowing: the checker watches your ordinary runtime checks — if, typeof, switch — and refines the static type along each control-flow path. It's the feature that makes TypeScript feel intelligent ("how did it know?"), it's what strictNullChecks relies on to be livable, and its patterns — discriminated unions, type predicates, exhaustiveness — are the daily working vocabulary of production TypeScript. This page is the complete toolkit.

1. Control-flow analysis: the checker follows your branches

The core mechanism: a variable's type is not fixed — it's tracked per location, updated by every check the checker understands:

typescript
function format(id: string | number) {
  // here: id is string | number
  if (typeof id === "string") {
    return id.toUpperCase();     // here: id is string — string methods allowed
  }
  return id.toFixed(2);          // here: id is number — the string case RETURNED
}

Note the second branch: no else needed. The checker saw that the string path returns, so after the if, only number remains. This is control-flow analysis — the checker walks your branches, returns, throws, and assignments, maintaining the narrowed type at every point. It's why typeof/instanceof checks feel "understood by the editor": they are.

The built-in guards, each with its scope of usefulness:

typescript
if (typeof x === "string") { … }      // primitives — string/number/boolean/bigint/symbol/function
if (x instanceof Date) { … }          // class instances — via the prototype chain (3.6.4)
if ("radius" in shape) { … }          // property presence — structural discrimination
if (x !== null) { … }                 // equality — removes null from the union
if (x) { … }                          // truthiness — removes null/undefined AND "" / 0 ⚠
while (queue.length) { … }            // conditions narrow in loops too

The truthiness trap deserves its flag: if (x) on a string | null also excludes the legitimate value "" (and on numbers, 0) — the same ||-vs-?? bug from 3.6.7, reborn at the type level. For pure absence checks write x != null (the sanctioned loose-equality idiom: excludes exactly null and undefined) or explicit comparisons.

Also know where narrowing ends: the checker distrusts mutation across function boundaries. Narrow a let, then call a function that might reassign it, or capture it in a closure (3.6.2) invoked later — the narrowing resets to the declared type. const bindings narrow far better because they can't be reassigned; one more argument for const-by-default.

2. Discriminated unions: the pattern that carries the most weight

Structural guards (in, instanceof) work, but the pattern that scales is giving every union member a common literal-typed tag — a discriminant — and switching on it:

typescript
type Shape =
  | { kind: "circle"; radius: number }          // "kind" is the discriminant:
  | { kind: "square"; size: number }            // a literal type per variant
  | { kind: "rect";   w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;  // s IS the circle variant here
    case "square": return s.size ** 2;              // — payload access fully typed
    case "rect":   return s.w * s.h;
  }
}

Checking one string field convinces the checker of the entire variant shape — payloads, presence, everything. This is the TypeScript encoding of the sum types / pattern matching that ML-family languages (3.5) are famous for, and it's everywhere in real code: Redux actions (action.type), API results ({ ok: true; data } | { ok: false; error }), WebSocket messages, AST nodes (3.1), the state modeling of 3.7.2's staff question.

Complete it with exhaustiveness checking — the pattern that turns "did I handle every case?" into a compiler guarantee:

typescript
function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.size ** 2;
    case "rect":   return s.w * s.h;
    default: {
      const unreachable: never = s;   // [!code highlight] // s must be never here —
      throw new Error(`unhandled: ${JSON.stringify(unreachable)}`);
    }                                 // add a "triangle" variant → this line ERRORS,
  }                                   // pointing you at every switch to update
}

If every case is handled, s in default has type never (3.7.2) and the assignment compiles. Add a variant and s is now { kind: "triangle"; … }the assignment fails at compile time at every switch that needs updating. This is change management as a type error, and it's among the highest-value habits in the language.

3. Custom guards: type predicates and assertion functions

Built-in guards can't express your domain checks — so TypeScript lets a function's return type declare what a true result proves. A type predicate:

typescript
function isFish(pet: Fish | Bird): pet is Fish {   // ← "pet is Fish": the predicate
  return (pet as Fish).swim !== undefined;          //    true ⇒ checker narrows to Fish
}

if (isFish(pet)) pet.swim();       // narrowed — your function became a guard
const fish = pets.filter(isFish);  // Fish[] — predicates power filter's typing too

The signature pet is Fish is a contract: if I return true, treat the argument as Fish. The checker trusts the annotation, not the body — a wrong implementation lies to every caller, so keep predicates tiny, obvious, and tested. Their natural habitats: validation helpers (isNonEmpty, isApiError), Array.filter (turning (T | null)[] into T[] via (x): x is T => x != null), and wrapping schema checks (3.7.7 — Zod's safeParse is a predicate industrialized).

The imperative sibling — an assertion function — narrows by throwing instead of returning false:

typescript
function assertDefined<T>(x: T | undefined, msg: string): asserts x is T {
  if (x === undefined) throw new Error(msg);
}

const cfg = loadConfig();          // Config | undefined
assertDefined(cfg, "config missing");
cfg.port;                          // ✅ Config from here DOWN — no if-nesting

asserts x is T tells the checker: past this call, x is T — because otherwise we threw. (The variant asserts cond narrows from any boolean expression — how Node's assert is typed.) Predicates fit branching; assertions fit preconditions — "this must hold or we stop" — and they keep happy paths flat.

4. The escape hatches: as, ! — and their honest replacements

Sometimes you know more than the checker. TypeScript provides overrides — which are promises, not checks:

typescript
const el = document.getElementById("app") as HTMLCanvasElement;  // "trust me"
const user = data!;               // non-null assertion: "it's not null, promise"

Type assertions (as) don't convert anything (erasure! — 3.7.1); they silence the checker. Sanity limits apply (only assert between overlapping types; x as unknown as Y bypasses even that — a double promise), but within them, a wrong assertion compiles and detonates at runtime. The ! postfix is the same promise specialized to "not null/undefined" — and as 3.7.2's drill showed, clusters of ! are the smell of mis-modeled optionals. Discipline: every as/! is either provable-but-inexpressible (fine, comment why) or a validation you skipped (fix it with a guard, assertion function, or schema).

Two modern keywords remove most legitimate assertion needs:

as const — "freeze this literal at its most specific type, deeply readonly":

typescript
const ROLES = ["admin", "editor", "viewer"] as const;
// type: readonly ["admin", "editor", "viewer"] — not string[]
type Role = typeof ROLES[number];        // "admin" | "editor" | "viewer" — derived!

Without as const, the array widens to string[] and the literals are lost. With it, you get the 3.7.2 single-source pattern: runtime array for iteration, literal union derived from it — no drift possible.

satisfies — "check against this type, but keep the narrower inference":

typescript
const palette = {
  primary: "#3b82f6",
  danger: [239, 68, 68],
} satisfies Record<string, string | [number, number, number]>;

palette.danger.map(c => c / 255);   // ✅ still knows danger is the TUPLE
// With `: Record<…>` annotation instead, danger would be string | tuple — widened ✗

An annotation replaces inference with the broader type; satisfies validates against it while preserving what inference learned. Use it for config objects, lookup tables, route maps — anywhere you want "conforms to the contract" and "remembers the specifics."

5. The expert lens

Narrowing is types meeting time. A static checker analyzing a dynamic language had two options: force annotations everywhere (Java-style casts after every check) or read the checks the language already writes. TypeScript chose the second, which is why idiomatic JavaScript — typeof, switch, early returns — becomes proof without ceremony. Consequence for style: code that narrows cleanly is code structured as guards and early returns; deeply nested conditionals with reassigned lets defeat both readers and the analyzer for the same reason. When the checker "loses" a narrowing you're sure of, the fix is usually structural (extract to const, split the function, add a discriminant) rather than an as.

The discriminant is an API design choice, not a TypeScript trick. Adding kind:/type:/status: tags to your wire formats and domain objects is what makes clients — in any typed language, and for that matter human readers and log queries — able to dispatch safely. Protobuf's oneof, Rust enums, GraphQL unions are the same idea across the stack. Designing a payload? Give alternatives a tag; your future switch statements (and everyone else's) inherit exhaustiveness for free.

Assertions are a budget. Every as and ! is a place where the proof burden moved from the compiler to a comment. Mature codebases track them: lint caps (no-non-null-assertion with per-line disables), review scrutiny proportional to blast radius, and the reflex of replacing them with predicates/schemas at boundaries. The metric isn't zero — it's each one deliberate.

Next: 3.7.4 — writing code that's generic over types without losing any of this precision: constraints, keyof, overloads, and variance from first principles.

Recall

  • Control-flow analysis tracks a value's type per location, refined by guards: typeof (primitives), instanceof (classes), in (property), equality, truthiness (⚠ also excludes ""/0 — prefer x != null for absence). Narrowing resets across reassignment/closures; const narrows best.
  • Discriminated unions — variants tagged with a literal kind — are the modeling pattern: one tag check types the whole payload. Complete every switch with the default: const _: never = x exhaustiveness idiom so new variants break every unhandled site at compile time.
  • Type predicates (x is T) make your functions guards (and type filter); assertion functions (asserts x is T) narrow by throwing — flat preconditions. The checker trusts the signature, so keep bodies tiny and correct.
  • Escape hatches promise, never check: as silences, ! promises non-null — each one is either provable-and-commented or a skipped validation. as const freezes literals (single-source union pattern); satisfies validates against a type without widening inference — the config-object tool.

Self-test: Why does the checker know the type after an early-return if with no else? What's the truthiness-narrowing trap? Write the exhaustiveness idiom from memory and explain what happens when a variant is added. What does the checker trust in pet is Fish — and what risk follows? When is satisfies better than an annotation?

Quiz Bank

FoundationalWhat is narrowing, and which checks does the compiler understand?

Narrowing is control-flow analysis refining a union type along each execution path: a variable's static type differs by location, updated by checks the compiler recognizes — typeof x === "…" (primitives), x instanceof C (prototype chain — 3.6.4), "prop" in x (structural), equality comparisons (x !== null, literal equality — including discriminant checks), truthiness (if (x) — beware it also excludes ""/0; use x != null for pure absence), plus return/throw terminating branches (after an early return, only the remaining union members survive) and switch cases. Custom checks join via type predicates and assertion functions. Limits: narrowing resets when the checker can't prove stability — reassigned lets across calls, captures in closures invoked later — which is why const + early-return style narrows best.

FoundationalWhat is a discriminated union and why is it the recommended modeling pattern?

A union whose every member carries a common property with a distinct literal type — the discriminant (kind: "circle" vs kind: "square"). Checking that one field (if/switch on s.kind) narrows to the entire variant shape, payload and all — safe, exhaustive dispatch with zero runtime machinery. It's recommended because it makes illegal states unrepresentable (versus one interface of optionals, where contradictory flag combinations type-check) and because it composes with exhaustiveness checking to turn "new variant added" into compile errors at every dispatch site. It's TypeScript's encoding of sum types/pattern matching (3.5) and the shape of Redux actions, API result types, and protocol messages.

AppliedWrite the exhaustiveness-checking idiom and explain exactly why it works.
typescript
switch (msg.type) {
  case "join":  return handleJoin(msg);
  case "leave": return handleLeave(msg);
  default: {
    const unreachable: never = msg;    // the exhaustiveness guard
    throw new Error(`unhandled: ${unreachable}`);
  }
}

Mechanism: each case eliminates a variant from the union; if all variants are handled, the type of msg in default is never (empty — nothing remains), and never is assignable to never, so it compiles. When someone adds { type: "kick"; … } to the union, msg in default now has that variant's type — not never — so the assignment is a compile error at every switch missing the case, with the error message pointing at each. The runtime throw is the belt-and-suspenders for un-typed callers (JS, bad casts). Same guarantee is available expression-style via a helper assertNever(x: never): never.

InterviewType predicates vs assertion functions — signatures, when to use each, and the shared risk.

A type predicate function isFish(x: Fish | Bird): x is Fish returns boolean; true narrows the argument at the call site — use for branching (if (isFish(p))) and for Array.filter ((x): x is T => x != null turns (T | null)[] into T[]). An assertion function function assertDefined<T>(x: T | undefined): asserts x is T returns nothing; not throwing is the proof, and the narrowing applies to all code after the call — use for preconditions, keeping happy paths flat instead of pyramid-nested (asserts cond is the boolean-expression variant, as in Node's assert). Shared risk: the checker trusts the signature, never the body — return true with the wrong logic silently mis-narrows every caller. So: bodies tiny and obvious, unit-tested, and at real trust boundaries prefer deriving them from schemas (3.7.7) so the runtime check and the claimed type can't drift.

InterviewCompare as, !, as const, and satisfies — which check, which promise, and when is each appropriate?

as (type assertion) and ! (non-null) are promises: they silence the checker, check nothing (erasure), and detonate at runtime if wrong. Legitimate when you can prove what the checker can't express (DOM element kinds, freshly-validated values) — commented, budgeted, lint-capped; clusters of ! signal mis-modeled optionals that want a discriminated union. as const is a narrowing request, fully checked: freeze a literal at its most specific, deeply-readonly type — the tool for const ROLES = [...] as const single-source unions and for keeping literal payload tags from widening. satisfies is a check without widening: validates an expression against a type while preserving inference's narrower conclusion — perfect for config/lookup objects where an annotation would smear every value to the broad union (palette.danger staying a tuple). Decision rule: want checking + specificity → satisfies/as const; want to override the checker → as/!, deliberately and visibly.

StaffA codebase has hundreds of scattered `if (obj && obj.data && !obj.error)` checks and regular production incidents when API variants change. Design the systematic fix and the enforcement that keeps it fixed.

Diagnosis: the API's alternatives are modeled implicitly — presence/absence of optional fields — so every consumer re-derives the state machine with ad-hoc truthy chains (which also mis-handle ""/0 — the truthiness trap), and when the backend adds a variant, no compiler signal reaches any call site; incidents are the discovery mechanism.

Fix in three moves. (1) Model: define the response as a discriminated union{ status: "ok"; data } | { status: "error"; error } | { status: "rate_limited"; retryAfter } — ideally negotiated into the wire format itself (a literal status tag is an API-design gift to every client — same idea as protobuf oneof). (2) Boundary: one ingress function parses unknown responses via schema into that union (3.7.7), so the union is earned, not asserted; all consumers import the type and the parser. (3) Dispatch: consumers switch on status with the never exhaustiveness guard — now the next added variant fails compilation at every unhandled switch, converting "incident" into "red CI."

Enforcement so it stays fixed: lint bans on the old patterns in API-touching paths (no-unsafe-member-access via typed responses, cap on !), the parser as the only exported way to obtain the type (constructor smuggling prevented by not exporting variant types raw), contract tests pinning schema↔backend, and a CI check that the generated/declared union matches the OpenAPI spec if one exists. Close the loop culturally: the pattern name ("discriminated union at the boundary, exhaustive switch at use") goes in the team's playbook — it's the reusable senior answer to every "backend added a case and we broke" incident.

Flashcards

FlashBuilt-in guards

typeof (primitives) · instanceof (classes) · in (property) · equality · truthiness (⚠ excludes ""/0 — use x != null for absence) · switch/early return.

FlashDiscriminated union

Variants share a literal-typed tag (kind/status); checking the tag narrows to the whole variant. The pattern for states, actions, API results.

FlashExhaustiveness idiom

default: const _: never = x. Compiles only if every variant handled; new variant = compile error at every switch. Change management as types.

Flashx is T / asserts x is T

Predicate: true ⇒ narrowed (branching, filter). Assertion: didn't throw ⇒ narrowed after (flat preconditions). Checker trusts the signature — keep bodies tiny.

Flashas const

Freeze literal at most-specific, deeply readonly type. Enables const ROLES = […] as const; type Role = typeof ROLES[number].

Flashsatisfies

Validate against a type WITHOUT widening inference — config objects keep their specific value types. Annotation replaces; satisfies preserves.

Scenario Drill

DrillBuild the types and handling for a WebSocket protocol: messages join, chat, presence, and error arrive as JSON strings; handlers must be payload-typed; unknown message types must not crash but must be logged; adding a message type next sprint must produce compile errors at every unhandled site. Show the design.

Wire model — a discriminated union on a literal type tag, derived from an as const registry so runtime and types share one source:

typescript
const MESSAGE_TYPES = ["join", "chat", "presence", "error"] as const;
type MessageType = typeof MESSAGE_TYPES[number];

type ServerMessage =
  | { type: "join";     user: string; room: string }
  | { type: "chat";     user: string; text: string; ts: number }
  | { type: "presence"; online: readonly string[] }
  | { type: "error";    code: number; message: string };

IngressJSON.parse yields unknown (3.7.2); a schema/predicate earns the union honestly, and unknown types are a parse outcome, not an exception: parseMessage(raw: string): ServerMessage | { type: "unknown"; raw: string } — validating the tag against MESSAGE_TYPES (runtime use of the as const array) and each payload's shape. Malformed input is data to route, so the "must not crash" requirement lives in the type: consumers cannot forget the unknown case because it's a variant. Dispatch — one exhaustive switch:

typescript
function handle(msg: ReturnType<typeof parseMessage>): void {
  switch (msg.type) {
    case "join":     return onJoin(msg);        // msg: the join variant — typed payload
    case "chat":     return onChat(msg);
    case "presence": return onPresence(msg);
    case "error":    return onServerError(msg);
    case "unknown":  return log.warn("unrecognized message", msg.raw);
    default: {
      const unreachable: never = msg;           // next sprint's guarantee
      throw new Error(`unhandled: ${unreachable}`);
    }
  }
}

Adding "typing" next sprint: extend the union + registry (one file), and the never guard turns every unhandled switch in the codebase into a compile error — the requirement met by mechanism, not memory. Handlers receive precise variants (no casts anywhere); readonly payload arrays keep handlers from mutating shared state; and if a handler set grows, a typed handler-map alternative ({ [K in ServerMessage["type"]]: (m: Extract<ServerMessage, { type: K }>) => void }, 3.7.5) makes missing keys the compile error instead. Principles exercised: tag on the wire, unknown at ingress, union earned by validation, exhaustive dispatch, single source for runtime+type — the complete narrowing toolkit in one feature.