TypeScript Type Narrowing
Union types are how TypeScript models values that can be one of several things: string | null, Success | Failure, Circle | Square. You can't use a union's members directly. You first have to prove which case you're in. Narrowing is how TypeScript follows your runtime checks (if, switch, early returns) and refines the type in each branch.
Narrowing is what makes strictNullChecks practical and discriminated unions safe, and it lets you model state machines where impossible states can't be represented. It's also where many "why is this still string | undefined?" frustrations come from, so knowing the rules pays off daily.
TL;DR
- TypeScript narrows via control-flow analysis: after
if (x !== null),xis non-null in that branch. - Built-in guards:
typeof,instanceof,in, equality (===,!==), and truthiness. - Discriminated unions share a literal "tag" field (
kind,type,status); checking it narrows to one variant. - Type predicates (
x is Foo) and assertion functions (asserts x is Foo) let you write reusable guards. - Use
neverfor exhaustiveness, so adding a new variant makes every unhandledswitcha compile error. - Narrowing doesn't survive callbacks or reassignments, and a predicate is only as correct as its implementation.
Quick Example
A discriminated union for request state, handled exhaustively:
There's no way to read data while loading, or to have data and error at once. The type makes those states unrepresentable.
Core Concepts
Built-In Narrowing
Discriminated Unions
Give each variant a common property with a distinct literal type. Checking that property narrows to exactly one variant:
This is the most important modeling pattern in TypeScript. It's the typed equivalent of Rust's enums and Kotlin's sealed classes, and it's the standard shape for Redux actions, API results, and UI state.
Type Predicates
Wrap reusable checks in a function returning value is Type:
Since TS 5.5, simple predicates like x => x !== null are inferred automatically, so .filter(u => u !== null) already yields User[].
Assertion Functions
An assertion function throws if a condition fails, and narrows for the rest of the scope:
Exhaustiveness With never
After every variant is handled, the remaining type is never. Assigning it to a never variable (or passing it to an assertNever(x: never) helper) turns "forgot a case" into a compile error the moment someone adds a new variant.
Where Narrowing Stops
- Callbacks: narrowing a
letvariable doesn't carry into closures that run later, because TypeScript can't know the variable wasn't reassigned. Copy to aconstfirst. - Property access on mutable objects:
if (obj.value)narrowsobj.value, but a function call in between may invalidate it. Store the property in a localconst. - Across function boundaries: a plain
boolean-returning helper doesn't narrow. Make it a type predicate. - Untrusted input: narrowing from
unknownneeds real runtime checks. For anything complex (API bodies, JSON), use a schema validator like Zod instead of hand-written predicates.
Best Practices
Model States as Discriminated Unions
Replace bags of optional fields ({ loading?: boolean; data?: T; error?: Error }) with a union of explicit states. You eliminate impossible combinations, and the compiler forces you to handle each state.
Prefer Narrowing to Assertions
x! and x as User tell the compiler to trust you, with no runtime check. An if or an assertion function checks at runtime and narrows. Reserve ! for cases where you've genuinely proven non-nullness in a way TypeScript can't follow.
Add an assertNever Helper
Use it in every default of a switch over a union. You get compile-time exhaustiveness and a clear runtime error if bad data arrives anyway.
Common Mistakes
Truthiness Narrowing That Drops Valid Values
Lying Type Predicates
The compiler trusts predicates completely. A wrong one spreads a false type through your codebase. Keep predicates thorough and tested, or generate them from a schema.
Using typeof for null and Arrays
typeof null === "object" and typeof [] === "object". Check x !== null and Array.isArray(x) explicitly.
FAQ
What's a discriminated union?
A union of object types that share one property, the discriminant, whose type is a different literal in each member (for example status: "loading" | "success" | "error"). Checking the discriminant narrows the value to a single member, so TypeScript knows exactly which other fields exist.
What's the difference between a type predicate and an assertion function?
A type predicate (x is T) returns a boolean; narrowing applies inside the if that uses it. An assertion function (asserts x is T) returns nothing and throws on failure; narrowing applies to all code after the call. Use predicates for branching and assertions for preconditions.
Why isn't my variable narrowed inside a callback?
Callbacks may run later, after the variable could have changed, so TypeScript discards narrowing for mutable let variables and object properties inside them. Assign the narrowed value to a const before the callback and use that.
How do I narrow unknown from JSON.parse?
With runtime checks (typeof, in, Array.isArray) wrapped in a type predicate, or, far more practically, with a schema library that validates and returns a typed value in one step.
Related Topics
- TypeScript — The language overview
- TypeScript Basics — Unions and basic guards
- TypeScript Generics — Generic guards and assertion helpers
- TypeScript Advanced Features —
satisfies, conditional types, and more - Zod — Runtime validation that narrows untrusted data
- Error Handling — Result types as discriminated unions