Skip to content

Lesson 2 of 9 · 22m

Narrowing: control flow as a type system feature

typeof, in, instanceof, type predicates, and the truthiness bug that ships to production.

A string | null is two types pretending to be one. Every if, switch, and ternary in your function is a chance to split them back apart — TypeScript calls it narrowing, and it's doing more work for you than most people realize. When it works, it feels like the compiler is reading your code; when it doesn't, the cause is almost always one of the handful of rules this lesson covers.

The three workhorses

typeof for primitives, in for object keys, instanceof for classes:

function size(x: string | string[] | { items: string[] } | Date): number {
  if (typeof x === "string") return x.length;   // x: string
  if (Array.isArray(x)) return x.length;        // x: string[]
  if ("items" in x) return x.items.length;      // x: { items: string[] }
  return x.getTime();                           // x: Date — only thing left
}

After each check, the variable's type inside the block is narrowed to what the check proved. Memorize Array.isArray — typeof [] === "object", so plain typeof can't see arrays.

Truthiness narrowing and the bug that ships

function greet(name?: string) {
  if (name) return `hi ${name}`;
  return "hi stranger";
}

if (name) narrows away undefined — but it also swallows "". If an empty string is a legal input (a trimmed username, a 0 retry count, NaN), truthiness narrowing silently eats it. This is the single most common narrowing bug in production code. When 0, "", or NaN are meaningful, narrow on !== undefined, or != null — the loose != null is the one place loose equality is idiomatic, matching null and undefined and nothing else.

Custom predicates: teaching the compiler your checks

function isUser(v: unknown): v is User {
  return typeof v === "object" && v !== null
    && "id" in v && "email" in v;
}

The v is User return annotation is a type predicate: after if (isUser(x)), x is User. Powerful, and a footgun — the compiler trusts the annotation completely. If your predicate lies, every call site gets a wrong type for free. Write predicates as real checks, keep them at trust boundaries, and prefer validating the shape (as above) over checking for a marker.

A subtlety people hit next: narrowing a variable doesn't always survive a function call for mutable state. If you narrow user.name to string and then call something that could reassign user, TypeScript may widen it back. Assign to a const first and the narrowing sticks — another reason to destructure early.

Try this

Write parsePort(v: string | number | undefined): number that returns a port or throws. Do it twice: once with truthiness checks, once with explicit v === undefined and typeof checks. Feed it "", 0, and "8080" and observe which version lies.

Pitfalls

  • typeof null === "object" — always has been, forever will be. Check x !== null first, or use != null.
  • instanceof fails across realms (iframes, workers, vm contexts): an array from another realm isn't an instanceof Array. Array.isArray works everywhere.
  • Narrowing is per-variable. if (obj.ok) narrows obj.ok for reads, but passing obj.ok as an argument doesn't carry the narrowing unless you aliased it first.
  • Current for TS 5.x; useUnknownInCatchVariables (covered in the tsconfig lesson) also changes how catch variables narrow.