Dataslope logoDataslope

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

in operator narrowing

The in operator checks if a property exists on an object. TypeScript uses this to narrow object unions:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

Narrowing with array methods

Some array methods (like filter) can narrow types if you use type predicates:

Code Block
TypeScript 5.7

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.

Challenge
TypeScript 5.7
Shape Area with Type Guards

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

QuestionSelect one

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) { ... }

QuestionSelect one

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.

On this page