Part 1 · 2 chapters · ~20 min

Inference and Narrowing

Narrowing as the checker's control-flow analysis over your branches (the graph, the operators, discriminated unions, where it stops, aliases, the cost), and the tools that extend it (type predicates, assertion functions, satisfies) with the rules of inference, contextual typing and generic inference that decide whether a literal survives.

3

Narrowing is control-flow analysis

code
// narrowing in practice: a union, a discriminant, a predicate, an assertion, and the two places narrowing is lost
type Shape = { kind: 'circle'; r: number } | { kind: 'square'; s: number } | { kind: 'tri'; b: number; h: number }

function area(s: Shape): number {
  switch (s.kind) {                                  // discriminant: each case narrows s
    case 'circle': return Math.PI * s.r ** 2
    case 'square': return s.s * s.s
    case 'tri':    return 0.5 * s.b * s.h
    default: { const never: never = s; return never }   // exhaustive: adding a kind is a compile error here (part 6)
  }
}

const isCircle = (s: Shape): s is Extract<Shape, { kind: 'circle' }> => s.kind === 'circle'   // a predicate: a promise the checker trusts
const circles = shapes.filter(isCircle)             // Circle[]

function assertPositive(n: number): asserts n is number & { __brand: 'positive' } { if (n <= 0) throw new RangeError() }   // an assertion (brand: part 6)

function describe(x: string | number | null, cb: (s: string) => void) {
  if (x === null) return                            // narrowed below: string | number
  if (typeof x === 'number') { x.toFixed(); return } // number here; string below
  x.toUpperCase()                                   // string: the early returns did it
  let y = x
  setTimeout(() => { /* y.toUpperCase() */ })        // ✗ lost: y is let and captured; the callback's flow node is disconnected
  const z = x
  setTimeout(() => z.toUpperCase())                 // ✓ const: narrowing survives capture
  if (getKind(x) === 'upper') { /* x not narrowed: the checker cannot see inside getKind */ }
}
const config = { retries: 3, mode: 'fast' } satisfies Config   // checked against Config; config.mode is 'fast', not string
the checker walks your branches
  1. The graph: every statement a node; if, switch, &&, ||, ?:, loops, returns and throws split and join it; a reference walks back to compute its type at that point. After an if, the join; unless a branch returned, which narrows everything below.
  2. The operators the checker knows: typeof, instanceof, in, equality with a literal, truthiness, discriminant property checks, Array.isArray, assignment, and user predicates and assertions (next chapter). A helper function is not on the list unless it is a predicate.
  3. Discriminated unions are the pattern the rest of the course builds on: a switch on kind narrows to the member; the default sees never when exhaustive (part 6); it depends on the discriminant staying a literal (part 0's widening).
  4. Where it stops, each with a reason in the graph: a property read through a function call (opaque); a let captured in a callback (the callback's flow node is disconnected; use const or copy to a local); an element access with a string key; a widened literal.
  5. Aliases and destructuring: a const alias of a condition narrows (since 4.4); a let alias does not; destructured discriminants narrow their siblings when destructured from a union parameter.
  6. The cost: per reference per function; a function with hundreds of branches is where the checker spends time (part 5); a depth limit silently stops narrowing in a very long chain. Splitting a long function is also a checker performance fix.
NARROWING IS CONTROL-FLOW ANALYSIS
the checker walks your branches and tracks what each reference could be at each point
swipe the figure sideways, or tap expand for full screen
1/6
the graph
The graph: each statement is a node; branches (if, switch, &&, ||, ?:, loops, early returns, throws) split and join it. For x: string | number, the reference to x inside if (typeof x === "string") has a flow node whose predecessor is the true branch of the condition; walking back, the checker applies the typeof narrowing and gets string. In the else, number. After the if, the join of both: string | number again, unless a branch returned.
4

Predicates, assertions, satisfies and inference

teaching the checker, and keeping inference honest
  1. Type predicates (s is Circle) put your function on the operator list; filter narrows arrays; 5.5 infers simple arrow predicates. A wrong predicate is a lie the compiler cannot catch: test it like code.
  2. Assertion functions (asserts x is T, or asserts condition) narrow the code after the call as a throw-on-false would; the call must be a statement of a declared function with an explicit type for the checker to see it. The typed form of "fail fast".
  3. satisfies checks a value against a type without widening it: a config keeps its literal properties, a route map keeps its literal keys. The answer to "I want checking but not widening".
  4. Inference from initialisers and returns: const keeps primitive literals, object properties widen, returns are the union of return statements with reduction, as const keeps everything and makes it readonly. Exported functions get explicit return types (part 6) because inference leaks implementation.
  5. Contextual typing: the expected type flows inward into callbacks and object literals, which is why "ok" survives in a typed position and widens to string in an untyped return. Annotating the variable or the return is the fix for the literal-not-assignable error.
  6. Generic inference: candidates from the arguments; a best common supertype or an error; const type parameters keep literals and tuples; NoInfer stops a position from contributing. An explicit type argument is the override; the real fix is usually the signature (part 2).
the exercise
Find a cast (as) in your codebase that exists because narrowing was lost. Replace it with a const, a predicate, an assertion or satisfies; if none fits, the cast was hiding a real type error.
PREDICATES, ASSERTIONS, SATISFIES AND INFERENCE
teaching the checker what a helper proves, and letting it infer without losing the literal
swipe the figure sideways, or tap expand for full screen
1/6
predicates
Type predicates: function isCircle(s: Shape): s is Circle { return s.kind === "circle" } makes if (isCircle(s)) narrow s to Circle, and the else to Exclude. The predicate is a promise the checker trusts; a wrong implementation (return true always) is a lie the compiler cannot catch. Array.prototype.filter with a predicate narrows the result array; inferred predicates (since 5.5) make simple arrow guards work without the annotation.