Back

Making Sense of Type Narrowing in TypeScript

Making Sense of Type Narrowing in TypeScript

You’ve written a type guard, but TypeScript still complains the property doesn’t exist. Or you’ve filtered an array, yet the resulting type remains a union. These frustrations stem from a gap between how you think narrowing works and how TypeScript’s control flow analysis actually operates.

This article builds a clear mental model of TypeScript type narrowing—how the compiler tracks types through your code and when it loses that information.

Key Takeaways

  • Type narrowing is TypeScript’s control flow analysis tracking types through execution paths based on runtime checks it can verify
  • Core narrowing mechanisms include typeof, instanceof, truthiness checks, equality checks, the in operator, and discriminated unions
  • User-defined type guards with type predicates allow custom narrowing, but TypeScript doesn’t verify your predicate logic
  • The never type enables compile-time exhaustiveness checking to catch unhandled cases
  • Narrowing breaks down across callback boundaries, property reassignments, and complex aliasing patterns

How Control Flow Analysis Works

TypeScript narrowing isn’t magic. It’s the compiler following your code’s execution paths and updating type information based on runtime checks it can verify.

When you write a conditional, TypeScript analyzes what must be true in each branch:

function process(value: string | number) {
  if (typeof value === 'string') {
    // TypeScript knows: value is string here
    return value.toUpperCase()
  }
  // TypeScript knows: value is number here
  return value.toFixed(2)
}

The compiler sees the typeof check, recognizes it as a narrowing construct, and refines the type accordingly. This happens automatically for several JavaScript operators.

Core Narrowing Mechanisms

typeof and instanceof

The typeof operator narrows primitive types reliably. TypeScript understands its quirks—including that typeof null returns "object".

function handle(x: unknown) {
  if (typeof x === 'string') return x.length
  if (typeof x === 'number') return x.toFixed(2)
}

For class instances, instanceof provides similar narrowing:

if (error instanceof TypeError) {
  console.log(error.message)
}

Truthiness and Equality

Truthiness checks narrow out null and undefined:

function greet(name: string | null) {
  if (name) {
    return `Hello, ${name}`  // name is string
  }
}

Equality checks narrow to literal types:

type Status = 'pending' | 'complete' | 'failed'

function handle(status: Status) {
  if (status === 'complete') {
    // status is 'complete'
  }
}

The in Operator and Discriminated Unions

The in operator checks for property existence, enabling duck-typing patterns:

if ('radius' in shape) {
  return Math.PI * shape.radius ** 2
}

Discriminated unions combine this with literal types for powerful narrowing patterns:

type Result<T> = 
  | { success: true; data: T }
  | { success: false; error: string }

function handle<T>(result: Result<T>) {
  if (result.success) {
    return result.data  // TypeScript knows data exists
  }
  return result.error   // TypeScript knows error exists
}

User-Defined Type Guards

When built-in checks aren’t enough, type predicates let you define custom guards:

function isString(value: unknown): value is string {
  return typeof value === 'string'
}

One caveat: TypeScript doesn’t verify your predicate logic. You’re asserting the relationship—if your check is wrong, the types will be wrong.

Modern TypeScript (5.5+) has improved here. Array.filter can now infer type predicates automatically for simple callbacks, eliminating a common pain point:

const mixed: (string | number)[] = ['a', 1, 'b', 2]
const strings = mixed.filter(x => typeof x === 'string')
// TypeScript infers string[] in many cases

Exhaustiveness Checking with never

The never type enables compile-time exhaustiveness checking:

type Circle = { kind: 'circle'; radius: number }
type Square = { kind: 'square'; side: number }
type Triangle = { kind: 'triangle'; base: number; height: number }
type Shape = Circle | Square | Triangle

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2
    case 'square': return shape.side ** 2
    case 'triangle': return 0.5 * shape.base * shape.height
    default:
      const _exhaustive: never = shape
      return _exhaustive
  }
}

If you add a new shape variant, TypeScript errors at the never assignment—forcing you to handle it.

What Narrowing Isn’t

Two constructs often confused with narrowing deserve clarification:

Type assertions (as) bypass the type system entirely. They don’t narrow—they override.

The satisfies operator validates that an expression matches a type without changing it. Useful for catching mistakes, but not a narrowing mechanism.

When Narrowing Breaks Down

TypeScript’s control flow analysis has limits. Narrowing doesn’t persist across:

  • Callback boundaries (though closures with const variables now preserve narrowing in TypeScript 5.4+)
  • Property reassignments between checks
  • Complex aliasing patterns

When narrowing fails unexpectedly, check whether the compiler can actually trace the control flow from your check to your usage.

Conclusion

Think of narrowing as TypeScript watching your runtime checks and updating its knowledge accordingly. The compiler is conservative—it only narrows when it can prove the refinement is sound.

Write checks that TypeScript can follow. Prefer discriminated unions over type assertions. Use exhaustiveness checking to catch missing cases at compile time rather than runtime.

The goal isn’t to fight the type system but to structure code so narrowing works naturally.

FAQs

TypeScript's control flow analysis doesn't persist narrowing across callback boundaries because the callback might execute later when the variable's type could have changed. To work around this, assign the narrowed value to a const variable before the callback, or use a type guard inside the callback itself.

A type guard is a runtime check that TypeScript recognizes and uses to narrow types safely. A type assertion using 'as' tells TypeScript to treat a value as a specific type without any runtime verification. Type guards are safer because they involve actual checks, while assertions can hide bugs if your assumption is wrong.

Use discriminated unions when you have related types that share a common literal property like 'kind' or 'type'. They provide automatic narrowing in switch statements and enable exhaustiveness checking. Type guards are better for validating external data or when you can't modify the types you're working with.

This usually means TypeScript lost track of your narrowing. Common causes include checking a property then accessing it through a different reference, reassigning the variable between check and use, or the check happening in a different scope. Move your type check closer to where you use the value, or store the narrowed value in a new const variable.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay