Type Narrowing

How TypeScript figures out, step by step, which specific branch of a union type you're actually holding at a given point in the code.

What is it?

When a value has a union type like string | number, TypeScript won't let you use it as if it were definitely one or the other — calling .toUpperCase() on it is rejected, because that method only exists on strings, and the value might be a number.

But once you write a runtime check that only makes sense for one branch — like typeof value === "string" — TypeScript is smart enough to notice, and inside the block where that check passed, it treats value as definitely a string, unlocking string-only methods without any extra casting or annotation. This automatic, check-by-check narrowing of a wider type down to a more specific one is called type narrowing.

TypeScript recognizes several common narrowing checks: typeof (for primitives like string, number, boolean), instanceof (for checking if something is an instance of a particular class), plain truthy/falsy checks (if (value)), and checking for a specific property that only exists on one branch of the union.

Explain like I'm 10

It's like sorting mail before opening it. You can't read a letter's contents until you check what kind of envelope it's in — but once you notice it's the 'electric bill' envelope specifically, you now know exactly what's inside and can act on it directly, without guessing.

Examples

Narrowing with typeof

function printLength(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase()); // value is narrowed to string here
  } else {
    console.log(value.toFixed(2));    // value is narrowed to number here
  }
}

Inside the if block, TypeScript treats value as a string because that's the only way the typeof check could have passed. Inside the else, it's narrowed to number instead — the only remaining possibility.

Narrowing with instanceof and property checks

class Dog {
  bark() { console.log("Woof!"); }
}

class Cat {
  meow() { console.log("Meow!"); }
}

function speak(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark(); // narrowed to Dog
  } else {
    animal.meow(); // narrowed to Cat
  }
}

interface Success {
  ok: true;
  data: string;
}
interface Failure {
  ok: false;
  error: string;
}

function handle(result: Success | Failure) {
  if (result.ok) {
    console.log(result.data);  // narrowed to Success
  } else {
    console.log(result.error); // narrowed to Failure
  }
}

instanceof narrows between classes. Checking a shared property with a distinct literal value (like ok: true vs. ok: false) narrows between interfaces — this pattern is called a discriminated union.

How it works

The compiler tracks, at every point in your code, the narrowest type it can prove a variable has based on everything it's seen so far — this is called control flow analysis. When it sees a recognized check (typeof, instanceof, a truthy check, comparing a shared "tag" property to a specific value, and a few others), it narrows the variable's type within the branch where that check is known to be true, and narrows it differently — or back to the original type — outside that branch. This happens purely by analyzing the code's structure; no runtime type information is added or checked beyond the check you actually wrote.

Why does it exist?

Union types are only useful if you can eventually narrow them down to act on a specific branch — otherwise you'd be stuck being unable to use any branch-specific behavior at all. Narrowing lets TypeScript recognize the same runtime checks you'd write anyway (an if, a typeof) and reward them with more precise types, without requiring you to write anything extra just for the type checker's benefit.

When to use it

Whenever you're working with a union type and need to do something specific to one branch of it, write the narrowing check first (typeof, instanceof, a property check) before accessing anything specific to that branch. Discriminated unions (interfaces sharing one "tag" property with distinct literal values) are a particularly clean, common pattern for representing success/failure or variant states.

When not to use it

Narrowing isn't something you opt in or out of — it happens automatically whenever you write a recognized check. The mistake to avoid isn't using narrowing, but reaching for a manual type cast (as SomeType) instead of a proper runtime check, which tells the compiler to trust you without actually verifying anything at runtime.

Common mistakes

  • Using a type cast (as string) to silence an error instead of writing an actual runtime check, which provides no real safety and can be wrong.

  • Expecting narrowing to persist after calling another function in between — TypeScript can lose track of a narrowed type if a function call happens between the check and the use, since the function could theoretically change the value.

  • Forgetting that instanceof only works for narrowing between classes, not plain object shapes defined with interfaces — those need a discriminant property check instead.

Practice exercises

  1. Easy:

    Write a function formatValue(value: string | boolean) that uppercases the value if it's a string, and returns "YES"/"NO" if it's a boolean, using typeof narrowing.

  2. Medium:

    Define two interfaces, Circle { kind: "circle", radius: number } and Square { kind: "square", side: number }, then write a function area(shape: Circle | Square): number using a discriminated union check on kind.

  3. Hard:

    Write a function that accepts unknown and safely narrows it down step by step (checking it's an object, then that it has a specific property with the right type) before using it — without any as casts.

Interview questions

What is type narrowing?

The process by which TypeScript refines a value's type to something more specific within a particular branch of code, based on a runtime check like typeof, instanceof, or a property comparison.

Why does TypeScript disallow calling `.toUpperCase()` on a value typed `string | number` before any check?

Since the value could be a number at that point, and .toUpperCase() doesn't exist on numbers, the call isn't valid for every possibility the union allows — narrowing first proves which branch you actually have.

Inside `if (typeof value === "string") { ... } else { ... }` for a `value: string | number`, what is `value`'s type in each branch?

string inside the if block, since that's the only way the check could have passed, and number inside the else, since it's the only branch left once string is ruled out.

What can `instanceof` narrow between, and what can't it narrow between?

It narrows between classes, checking whether a value is an instance of a particular one — it doesn't work for narrowing between plain object shapes defined with interfaces, since those have no runtime class to check against.

What is a discriminated union, and what property makes narrowing on it reliable?

A union of object types that all share one common property (the discriminant) holding a distinct literal value per type — checking that one property's value lets TypeScript narrow to the exact matching branch.

Given `Success { ok: true; ... }` and `Failure { ok: false; ... }`, why does `if (result.ok)` narrow `result` to `Success`?

Only the Success branch has ok typed as the literal true, so a check that ok is truthy can only pass when result is actually a Success, and TypeScript narrows accordingly.

What is control flow analysis, and how does it relate to narrowing?

It's the compiler tracking, at every point in the code, the narrowest type it can prove a variable has based on everything seen so far — narrowing is the visible effect of that analysis when it recognizes a check like typeof or instanceof.

How does a plain truthy check like `if (value)` act as a narrowing mechanism?

It rules out falsy values (null, undefined, 0, "", NaN, false) from the type within the if block, narrowing a type like string | null down to just string.

Why is a type cast like `value as string` considered less safe than proper narrowing?

A cast tells the compiler to trust your claim about the value's type without verifying anything, whereas narrowing is based on an actual runtime check the compiler can confirm was performed before allowing type-specific use.

Why can narrowing be "lost" if you call another function between the check and the use of the narrowed value?

TypeScript can't guarantee the function call didn't change the value in the meantime (e.g. through a shared mutable variable), so it conservatively falls back to the wider, original type rather than assuming the narrowing still holds.

How would you safely narrow a value typed `unknown` down to a specific shape without using `as` casts?

Step by step with runtime checks — first confirm it's an object (and not null) with typeof value === "object" && value !== null, then check for the specific property you need with an in check or a typeof check on that property, narrowing further at each step.

When would you reach for `typeof` versus `instanceof` to narrow a union?

typeof for primitives like string, number, and boolean; instanceof for distinguishing between values that are instances of different classes.

Why doesn't `instanceof` work to narrow between two plain interfaces with no classes involved?

instanceof checks against a runtime class/constructor, but interfaces produce no such runtime construct — narrowing between interfaces instead requires checking a distinguishing property, as in a discriminated union.

What's the classic trap with using `typeof value === "object"` to narrow a union that might include `null`?

typeof null evaluates to "object" in JavaScript, so this check alone doesn't rule out null — you'd still need an explicit value !== null check alongside it.

How does checking for a property with the `in` operator (e.g. `"bark" in animal`) act as a narrowing check?

If only one branch of a union declares that property, TypeScript recognizes that a passing in check proves the value must be from the branch that actually has it, narrowing accordingly.

Given `function handle(x: string[] | undefined) { if (x) { ... } }`, what's `x`'s narrowed type inside the `if`, and what does that check *not* guarantee?

It narrows to string[] inside the block, since the truthy check rules out undefined — but it does not guarantee the array is non-empty, since an empty array is still truthy.

Why is control flow analysis purely a compile-time, static process, adding no runtime type information beyond what you actually wrote?

The compiler only reasons about which checks appear in the code's structure and where — it doesn't inject any extra runtime checks or metadata; narrowing purely reflects the existing checks you wrote, interpreted more precisely.

What breaks if two branches of an intended discriminated union accidentally use the same literal value for their shared tag property?

TypeScript can no longer distinguish the branches by that property, since a passing check for the shared value no longer implies which specific branch you have — narrowing on that tag stops working correctly.

If a narrowed variable is reassigned to a wider value inside the same branch, does the narrowed type persist for the rest of that branch?

No — control flow analysis re-evaluates the type after every assignment, so assigning a wider value widens the tracked type again from that point forward, regardless of the earlier narrowing check.

How does a truthy check like `if (value)` differ in behavior from an explicit `if (value !== undefined)` when narrowing `string | undefined`?

A truthy check also excludes an empty string "" from the narrowed branch even though "" is a valid, defined string, whereas the explicit !== undefined check only rules out undefined and leaves "" in the narrowed string branch.

Why doesn't calling an arbitrary custom function like `isString(value)` automatically narrow a union the way `typeof value === "string"` does?

TypeScript only recognizes a fixed set of built-in check patterns for narrowing by default — a plain function call's return type doesn't inform the compiler which branch was proven true unless the function is specifically declared as a type guard.

What is a user-defined type guard, and how does it let a custom function participate in narrowing?

A function whose return type is a type predicate, written as value is string, which tells the compiler that a truthy return specifically proves the argument is a string, letting a custom check narrow a union just like a built-in typeof check does.

For `animal: Dog | Cat`, why does `else` alone (after `if (animal instanceof Dog)`) safely narrow to `Cat` without a second `instanceof` check?

Once the if branch's check for Dog is ruled out, Cat is the only remaining possibility in the union, so control flow analysis narrows the else branch to it by elimination, without needing an explicit check for Cat.

Can narrowing be lost inside a closure or callback defined within an already-narrowed branch, such as inside a `setTimeout` callback?

Yes, for the same reason narrowing can be lost across a function call — TypeScript can't guarantee the outer variable wasn't reassigned by the time the callback actually runs, so it conservatively widens the type back inside the callback.

When is a discriminated union preferable to an `instanceof` check for distinguishing variants?

Discriminated unions work for plain data shapes (interfaces) with no classes involved and are easy to serialize, while instanceof is the natural choice when the variants are actual classes carrying their own behavior (methods).

What guarantee do you lose by using an `as` cast instead of a genuine narrowing check?

You lose the compiler's verification that the value actually is what you claim — a mistaken cast compiles cleanly but can fail at runtime in a way a real narrowing check, tied to an actual runtime test, would have caught.

Given `function printLength(value: string | number) { if (typeof value !== "string") { value.toFixed(2); } }`, is calling `.toFixed(2)` inside the `if` valid, and why?

Yes — a negated typeof check also narrows: since the branch is entered only when value is not a string, and the only other possibility in the union is number, TypeScript narrows value to number there.

Why is narrowing described as something you don't opt into or out of, and what does that imply about how to write runtime checks generally?

It happens automatically whenever the compiler recognizes a supported check pattern, so writing the runtime checks you'd naturally write anyway (an if, a typeof) is enough — there's no separate, type-checker-only annotation needed to unlock the narrower type.