AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 08 — Narrowing: Refining Types as Code Runs

08 — Narrowing: Refining Types as Code Runs

August 13, 20266 min read
Download as Markdown

"The compiler just figures it out" was my narrowing model, which made unions feel like a fight. Writing it down made the mechanism explicit: narrowing is the compiler refining a broad type to a more specific one based on checks in the code, and type guards are the specific checks (typeof, instanceof, equality, truthiness, predicates) that trigger the refinement. [1] Once I could name the triggers, union types stopped being awkward to work with.

The problem narrowing solves is direct. A variable typed string | number could be either. Inside a function I want to call .toUpperCase() if it's a string and .toFixed() if it's a number — but the compiler only allows operations common to all members of the union. Without narrowing, neither method is safe. Narrowing is how I prove to the compiler, at a specific point in the code, that the value is one specific member, unlocking that member's operations.

value: string | number if (typeof value) narrowed to string value.toUpperCase() narrowed to number value.toFixed(2)

typeof guards

The typeof check narrows based on JavaScript's runtime typeof operator [1]. For the primitives it knows about — string, number, boolean, symbol, undefined, "function" — a typeof comparison tells the compiler which branch holds which type:

function format(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // safe: narrowed to string
}
return value.toFixed(2); // safe: narrowed to number
}

Inside the if block, the compiler treats value as string; after the if, it knows the string case is exhausted, so value is number. typeof is the go-to for primitive unions because it reads at runtime exactly the way the compiler interprets it.

instanceof guards

The instanceof operator narrows based on an object's prototype chain — whether it's an instance of a specific class or constructor [2]:

if (error instanceof TypeError) {
error.message; // safe: narrowed to TypeError
}

This is the right tool when a union contains different class types (e.g., Error | TypeError | SyntaxError). The check works at runtime by walking the prototype chain, and the compiler mirrors that to narrow the type. It only works on values that have a constructor, so it's for class-based unions, not primitives.

Equality narrowing

Equality checks (===, !==, ==, !=) narrow by comparing a value against a specific literal [3]:

function respond(method: "GET" | "POST") {
if (method === "GET") {
// method narrowed to "GET"
}
}

This is especially powerful with literal unions and discriminated unions — comparing against a literal value (or another variable of a known type) lets the compiler rule out that case and narrow to the rest. Equality narrowing is what makes discriminated unions (where each member has a shared literal kind field) feel like a typed switch.

Truthiness narrowing

Truthiness checks narrow based on whether a value is truthy or falsy [4]. null, undefined, 0, "", NaN, and false are falsy; everything else is truthy. A simple if (value) filters out null and undefined (and the other falsy values) from the type inside the block:

function greet(name: string | null) {
if (name) {
name.toUpperCase(); // safe: null filtered out
}
}

Truthiness is the everyday tool for optional values — the idiomatic if (maybeValue) before using it. The catch is that it also filters 0 and "", which is usually fine but occasionally a bug when those are legitimate values; a strict === null check is the precise alternative when I only want to exclude absence.

Type predicates: custom guards

When the built-in checks aren't enough, a type predicate lets me write a function that narrows for the compiler [1]. The syntax is x is Type as a return type:

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

if (isString(maybe)) {
maybe.toUpperCase(); // safe: predicate narrowed it
}

The function returns a boolean at runtime, but the value is string return type tells the compiler: when this returns true, treat the argument as string. Type predicates are how I encapsulate complex narrowing logic — checking a discriminated union's shape, validating parsed data, or building reusable guards — and reuse it across a codebase. They're the bridge between runtime validation and compile-time types.

How I use this

My narrowing ladder, from most to least common: if (value) truthiness for optional values; typeof for primitive unions; equality checks for literal and discriminated unions; instanceof for class unions; and a type predicate when I need to reuse a check or express something the built-ins can't. The mental shift is that narrowing isn't magic — it's the compiler tracking control flow through specific, nameable checks. When a narrowing doesn't work as expected, I ask which trigger I'm missing, and the answer is usually "extract a type predicate."

References

[1] Microsoft, "typeof type guards" and "Using type predicates," TypeScript Handbook, 2024. [Online]. Available: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#typeof-type-guards

[2] Microsoft, "instanceof narrowing," TypeScript Handbook, 2024. [Online]. Available: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#instanceof-narrowing

[3] Microsoft, "Equality narrowing," TypeScript Handbook, 2024. [Online]. Available: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#equality-narrowing

[4] Microsoft, "Truthiness narrowing," TypeScript Handbook, 2024. [Online]. Available: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing

Knowledge check · Question 1 of 5

A variable is `string | number`. Inside `if (typeof value === "string")`, what is the type of value?

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!