Type Narrowing
Control flow analysis and type guards that refine union types branch by branch
When you work with union types (string | number, Shape, Result<T>),
the compiler doesn't initially know which member of the union you have.
But TypeScript is smart: it tracks control flow — if checks, switch
statements, throws — and narrows the type in each branch based on
what your code reveals. This is called type narrowing (or "control
flow analysis"), and it's one of TypeScript's most powerful features.
This chapter covers: typeof guards, instanceof guards,
equality narrowing, in operator narrowing, truthiness
narrowing, user-defined type predicates, and assertion
functions.
The narrowing problem
Given a union type, you can only access properties or call methods that exist on all members:
To call value.length, you need to prove to the compiler that value is
a string. That's where narrowing comes in.
typeof guards
The typeof operator checks the primitive type at runtime. TypeScript
recognizes typeof checks and narrows the type accordingly:
What the compiler now knows: Inside the if branch, value is
definitely a string. Inside the else branch, it's definitely a
number. The narrowing is automatic — you don't need a cast or
assertion.
typeof works for all primitive types:
Each branch refines the type. By the final else, the compiler knows
value is undefined.
instanceof guards
The instanceof operator checks if a value is an instance of a
class. TypeScript narrows based on instanceof:
instanceof is essential for narrowing class-based unions. It works with
any JavaScript constructor (including built-ins like Date, Array,
Error).
Equality narrowing
Comparing a value to a literal (via ===, !==, ==, !=) also
narrows the type:
The check value === null proves that value is null in the if
branch, and not null in the else branch (so it must be string).
You can also compare against literal types:
in operator narrowing
The in operator checks if a property exists on an object. TypeScript
uses this to narrow object unions:
The check "radius" in shape proves that shape has a radius
property, so it must be a Circle.
Truthiness narrowing
TypeScript can narrow based on truthiness checks — if a value is truthy or falsy:
The check if (value) eliminates null and undefined (both falsy),
leaving only string. This is a common pattern for handling optional
values.
Caveat: Truthiness narrowing can be imprecise. The empty string ""
is falsy, so if (value) will treat it the same as null. If you need
to distinguish "" from null, use an explicit check: if (value !== null && value !== undefined).
Control flow with switch
The switch statement also triggers narrowing:
The switch on result.status narrows result in each case.
User-defined type predicates
Sometimes the compiler can't infer narrowing on its own. You can define a
type predicate — a function that returns x is T instead of
boolean:
The return type value is string tells the compiler: "If this function
returns true, then value is a string in the caller's scope." This
is more powerful than returning boolean, because it lets you encapsulate
complex checks in a reusable function.
Another example with custom objects:
The predicate isDog narrows Dog | Cat based on the presence of
breed.
Assertion functions
TypeScript 3.7+ supports assertion functions — functions that throw if a condition is false, and assert the type if they return:
The return type asserts value is string tells the compiler: "If this
function returns (doesn't throw), then value is a string from that
point forward." This is like a type predicate, but it affects the type
after the call, not in an if branch.
Assertion functions are useful for validation logic:
Narrowing with array methods
Some array methods (like filter) can narrow types if you use type
predicates:
The predicate (x): x is string tells the compiler that the returned
array contains only strings.
Practice: a shape area calculator with narrowing
Let's solidify these concepts with a challenge. You'll define a discriminated union for shapes and implement a function that computes area using narrowing.
Define a discriminated union Shape = Circle | Rectangle | Triangle where Circle = { kind: "circle"; radius: number }, Rectangle = { kind: "rectangle"; width: number; height: number }, and Triangle = { kind: "triangle"; base: number; height: number }. Implement area(shape: Shape): number that computes the area (circle: π·r², rectangle: w·h, triangle: ½·b·h). Use a switch statement with exhaustiveness checking.
Check your understanding
Which of the following checks will narrow value: string | number to string?
if (value) { ... }
if (typeof value === "string") { ... }
if (value instanceof String) { ... }
if ("length" in value) { ... }
What does a type predicate like function isString(x: unknown): x is string do?
It converts x to a string at runtime
It tells the compiler that x is a string if the function returns true
It asserts that x is always a string
It's a syntax error
Summary
Type narrowing is TypeScript's way of tracking what your code proves
about a value's type. typeof, instanceof, ===, in, and truthiness
checks all trigger narrowing. You can define custom narrowing logic with
type predicates (x is T) and assertion functions (asserts x is T).
The compiler tracks control flow through if, switch, and even
early-return patterns, refining the type in each branch.
This is the magic that makes union types practical: you declare a broad
type (string | number | null), then let the compiler track the
narrowing as you drill down to specifics. No casts, no any, no
runtime overhead — just compile-time proof that your code is safe.
In the next chapter, Any, Unknown,
Never, we'll explore the three "special" types:
any (the escape hatch), unknown (the safe top type), and never (the
uninhabited bottom type). They're the edges of the type lattice, and
understanding them is key to mastering TypeScript.