Narrowing: How TypeScript Thinks

Beginner · 22 min read · ▶ live playground · ✦ checkpoint

Union types give you flexibility — string | number says "either one." But flexibility has a price: you can only use what both could do. Narrowing is how you pay it back. Write an ordinary if, and the checker follows your logic like a detective, eliminating suspects line by line — this is the lesson where TypeScript stops feeling like paperwork and starts feeling like a colleague.

A union is a promise

When a value's type is string | number, TypeScript holds you to the honest reading: it could be either. So it only lets you do things that are safe for every member of the union. .toFixed() exists on numbers but not strings — so on the union, it's an error:

function printId(id: string | number) {
  console.log(id.toFixed(2)); // not safe for the string case
}

→ error TS2339: Property 'toFixed' does not exist on type 'string | number'. Property 'toFixed' does not exist on type 'string'.

Read the two-part message closely: the checker even names the member that breaks the deal. The union promised "maybe a string," and strings can't toFixed.

typeof — teaching the checker with an if

Here's the beautiful part: TypeScript already understands the JavaScript you'd naturally write. Check typeof, and inside that branch the union shrinks:

function describe(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.trim();          // value: string here
  }
  if (typeof value === "number") {
    return value.toFixed(2);      // value: number here
  }
  return value ? "yes" : "no";    // value: boolean — the only one left
}
 
console.log(describe("  padded  "));
console.log(describe(3.14159));
console.log(describe(true));

→ padded / 3.14 / yes — hover value on each line in your editor: the type is different every time. Same variable, three types.

Notice the last line needs no check at all. The checker did the elimination: not a string, not a number, and the union only had three members — what remains must be boolean. Narrowing works in both directions: proving what something is, and proving what it no longer can be.

Try it — watch a union shrink

Hit Run — it type-checks first, and if the check fails, nothing runs, exactly like tsc on your machine. Then type your own statements at the > prompt — try format("all good"), then format(true) and read the real diagnostic. Variables and types you create carry over between entries.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

Truthiness, equality & early returns

Narrowing isn't only typeof. Any check the checker can follow narrows the type — truthiness, === comparisons, and the early-return pattern you'll use daily:

function greet(name: string | undefined): string {
  if (name === undefined) {
    return "Hello, stranger";
  }
  return "Hello, " + name.toUpperCase(); // name: string — undefined was returned away
}
 
console.log(greet(undefined));
console.log(greet("ada"));

→ Hello, stranger / Hello, ADA — after the early return, undefined is eliminated for the rest of the function. Guard clauses ARE narrowing.

The truthiness blind spot

Truthiness narrowing — if (value) … — is tempting shorthand, but several perfectly valid values are falsy, and two of them are often real data:

  • if (count) skips 0 — a perfectly valid count.
  • if (message) skips "" — a perfectly valid empty string.
  • Prefer the explicit check: if (count !== undefined).

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion