Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 27, 2026·11 min read

Narrowing & Control Flow Analysis

TypeScript tracks type information through every branch of your code. Control Flow Analysis is the engine under narrowing — understanding it eliminates every `as` cast in application code and makes discriminated unions the most powerful pattern in the language.

Narrowing & Control Flow Analysis

Types are a specification language — not an annotation layer. That discipline shows up most concretely in narrowing: TypeScript does not assign a single type to a variable and call it done. It tracks the precise type at each node in the control flow graph. Every if, switch, while, and early return updates that tracked type. Understanding this engine — Control Flow Analysis — is what lets you eliminate every as cast from application code.

1. Control Flow Analysis — The Engine Under Narrowing

TypeScript's compiler builds a Control Flow Graph (CFG) for every function body. Each node in the graph carries the type of every variable as it is known at that point in the execution path.

1.1 A Minimal Example

typescript
function greet(value: string | null): string {
  // At this point: value is string | null

  if (value === null) {
    // Inside this branch: value is null
    return 'Hello, stranger'
  }

  // After the null branch exits: value is string
  return `Hello, ${value.toUpperCase()}` // ✅ Safe — no null check needed
}
TypeScript sees the value === null check and narrows the type inside the branch to null. After the branch (which returns), the remaining path can only reach code where value is string. No explicit cast required.
 Flow graph diagram with three nodes. Top node labeled 'Entry: value: string | null'. Left arrow labeled 'value === null (true branch)' points to a left node labeled 'branch body: value: null → return'. Right arrow labeled 'value !== null (false path, falls through)' points to a right node labeled 'post-branch: value: string → toUpperCase() safe'. Caption: 'CFA assigns a specific type to each node in the control flow graph — after an eliminating branch, the remaining path carries the narrowed type'.
Figure: Flow graph diagram with three nodes. Top node labeled 'Entry: value: string | null'. Left arrow labeled 'value === null (true branch)' points to a left node labeled 'branch body: value: null → return'. Right arrow labeled 'value !== null (false path, falls through)' points to a right node labeled 'post-branch: value: string → toUpperCase() safe'. Caption: 'CFA assigns a specific type to each node in the control flow graph — after an eliminating branch, the remaining path carries the narrowed type'.

2. Built-in Narrowing Operators

2.1 typeof Narrowing

typeof returns one of eight string literals: "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function". TypeScript maps each return value to the correct type.
typescript
function format(value: string | number | boolean): string {
  if (typeof value === 'string') {
    return value.toUpperCase()   // value: string
  }
  if (typeof value === 'number') {
    return value.toFixed(2)      // value: number
  }
  // value: boolean — only remaining member
  return value ? 'yes' : 'no'
}
Performance / Safety Warning
typeof null === "object" — this is a 26-year-old JavaScript spec quirk that TypeScript inherits. Always check value === null explicitly before checking typeof value === "object" if your union includes null.

2.2 instanceof Narrowing

instanceof checks the prototype chain and narrows to the constructor's instance type:
typescript
function handleError(error: Error | string): string {
  if (error instanceof TypeError) {
    return `Type error: ${error.message}`  // error: TypeError
  }
  if (error instanceof Error) {
    return `Error: ${error.message}`       // error: Error (but not TypeError — already handled)
  }
  return error                             // error: string
}

2.3 in Narrowing — Discriminating Object Shapes

The in operator checks whether a property exists on an object. TypeScript uses it to narrow union members by their property presence:
typescript
type Cat = { meow(): void }
type Dog = { bark(): void }

function speak(animal: Cat | Dog): void {
  if ('meow' in animal) {
    animal.meow()  // animal: Cat
  } else {
    animal.bark()  // animal: Dog
  }
}

2.4 Truthiness Narrowing

TypeScript narrows to eliminate null, undefined, 0, "", and false from a union when you use a value in a boolean context:
typescript
function printName(name: string | null | undefined): void {
  if (name) {
    // name: string — null and undefined are falsy and have been eliminated
    console.log(name.toUpperCase())
  }
}
Performance / Safety Warning
Truthiness narrowing eliminates "" (empty string) from string, which is often not what you intend. If an empty string is a valid value, check !== null && !== undefined explicitly rather than using a truthiness check.

3. Discriminated Unions — The Canonical Narrowing Target

A discriminated union is a union of object types that all share a common literal-typed field (the discriminant). TypeScript's CFA uses the discriminant field to perform exhaustive narrowing.

3.1 Building a Discriminated Union

typescript
// ❌ Without a discriminant — narrowing requires checking for optional properties
type Shape =
  | { radius: number }     // circle?
  | { width: number; height: number }  // rectangle?

// ✅ With a literal discriminant — narrowing is precise and readable
type Circle   = { kind: 'circle';    radius: number }
type Rectangle = { kind: 'rectangle'; width: number; height: number }
type Triangle = { kind: 'triangle';  base: number; height: number }
type Shape = Circle | Rectangle | Triangle

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2  // shape: Circle
    case 'rectangle':
      return shape.width * shape.height   // shape: Rectangle
    case 'triangle':
      return 0.5 * shape.base * shape.height  // shape: Triangle
  }
}
 Three-panel diagram. Left panel: union type Shape = Circle | Rectangle | Triangle with a kind field highlighted in each member. Center panel: a switch (shape.kind) statement with three case branches. Right panel: each branch shows the narrowed type — case 'circle' narrows to Circle, case 'rectangle' to Rectangle, case 'triangle' to Triangle. Arrows connect each case label to its narrowed type. Caption: 'The literal kind discriminant gives CFA a precise signal — each switch case narrows the union to exactly one member without any type predicates'.
Figure: Three-panel diagram. Left panel: union type Shape = Circle | Rectangle | Triangle with a kind field highlighted in each member. Center panel: a switch (shape.kind) statement with three case branches. Right panel: each branch shows the narrowed type — case 'circle' narrows to Circle, case 'rectangle' to Rectangle, case 'triangle' to Triangle. Arrows connect each case label to its narrowed type. Caption: 'The literal kind discriminant gives CFA a precise signal — each switch case narrows the union to exactly one member without any type predicates'.

4. User-Defined Type Predicates

When built-in narrowing is insufficient, you can write a type predicate function to teach TypeScript your custom narrowing logic:
typescript
// Without a type predicate — TypeScript does not know what `isUser` proves
function isUser(value: unknown): boolean {
  return typeof value === 'object' && value !== null && 'name' in value
}

const data: unknown = fetchUser()
if (isUser(data)) {
  console.log(data.name)  // ❌ Error: 'name' does not exist on type 'unknown'
}

// ✅ With a type predicate — the return type annotation `x is User` teaches CFA
interface User { name: string; email: string }

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as any).name === 'string' &&
    typeof (value as any).email === 'string'
  )
}

const data: unknown = fetchUser()
if (isUser(data)) {
  console.log(data.name)  // ✅ data: User — type is narrowed
}
Performance / Safety Warning
TypeScript trusts your type predicate unconditionally. If isUser contains a bug and returns true for a non-User, the compiler will not catch it — you have taken responsibility for the narrowing logic. This is why type predicates should be minimal and independently tested.

5. Assertion Functions

Assertion functions narrow the type for all code after the assertion call — not just inside a branch. They are the TypeScript 3.7+ pattern for imperative guard clauses:
typescript
// ❌ A regular function cannot narrow the type for the caller
function assertString(val: unknown): void {
  if (typeof val !== 'string') throw new Error('Not a string')
}

const input: unknown = getInput()
assertString(input)
input.toUpperCase()  // ❌ Error: input is still `unknown`

// ✅ An assertion function with `asserts` narrows for all following code
function assertString(val: unknown): asserts val is string {
  if (typeof val !== 'string') throw new Error(`Expected string, got ${typeof val}`)
}

const input: unknown = getInput()
assertString(input)
input.toUpperCase()  // ✅ input is narrowed to `string` for all code following the assertion

6. Exhaustiveness Checking with never

When you add a new member to a discriminated union, you want the compiler to find every switch statement that fails to handle it. The never exhaustiveness pattern achieves this:
typescript
type Status = 'idle' | 'loading' | 'success' | 'error'

function handleStatus(status: Status): string {
  switch (status) {
    case 'idle':    return 'Waiting...'
    case 'loading': return 'Loading...'
    case 'success': return 'Done!'
    case 'error':   return 'Failed!'
    default:
      // If 'cancelled' is added to Status without a case, this line errors:
      // Type 'string' is not assignable to type 'never'.
      const _exhaustive: never = status
      throw new Error(`Unhandled status: ${_exhaustive}`)
  }
}
Mental Model Check
After all known cases are handled, the default branch should be unreachable — status should be never. Assigning it to a never-typed variable makes that expectation explicit. When a new union member breaks the invariant, the assignment fails at compile time, not runtime.
 Diagram showing a discriminated union Status = 'idle' | 'loading' | 'success' | 'error' at the top. Four arrows from the union point into four case boxes. A fifth dotted arrow labeled 'unhandled members flow here' points to a default box. Inside the default box, text reads const _exhaustive: never = status. A red X icon is shown with the label 'Compile error if any union member reaches this point — never exhaustiveness catch'. Caption: 'The never assignment in the default branch is a compile-time completeness guarantee — adding a union member without a case surfaces as a type error immediately'.
Figure: Diagram showing a discriminated union Status = 'idle' | 'loading' | 'success' | 'error' at the top. Four arrows from the union point into four case boxes. A fifth dotted arrow labeled 'unhandled members flow here' points to a default box. Inside the default box, text reads const _exhaustive: never = status. A red X icon is shown with the label 'Compile error if any union member reaches this point — never exhaustiveness catch'. Caption: 'The never assignment in the default branch is a compile-time completeness guarantee — adding a union member without a case surfaces as a type error immediately'.

Summary

ConceptRule
CFATypeScript tracks the precise type at each control flow node
typeofNarrows to 8 possible primitive types; beware typeof null === "object"
instanceofNarrows via prototype chain; requires a constructor reference
inNarrows by property presence; the correct tool for duck-typing shapes
TruthinessEliminates falsy values including "" — only use when empty string is invalid
Discriminated unionCommon literal kind field gives CFA the clearest possible signal
Type predicatex is T — teaches CFA your custom narrowing; you own the correctness
Assertion functionasserts cond — narrows all code following the call, not just one branch
never exhaustivenessconst _e: never = x in default — compile-time union completeness guard

What's Next

In Part 3, we cover Generics & Constraints — the type-level equivalent of function parameters. Mastering them eliminates the category of any usage that exists solely because a developer didn't know how to preserve type information through a function call.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Narrowing#Type Guards#Control Flow#Discriminated Unions
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.