Type narrowing

TypeScript · Essentials · 12 min

Narrowing is how you work with unions safely; it is the day-to-day skill that makes TypeScript feel natural.

Narrowing is the checker following your code's control flow and refining a variable's type after each check. Inside if (typeof x === "string") { ... } a string | number is just string.

The checks TypeScript understands:

  • `typeof`: "string", "number", "boolean", "bigint", "symbol", "undefined", "function", "object". Beware: typeof null === "object", and TypeScript knows that — a typeof x === "object" check leaves null in the type.
  • Truthiness: if (value) removes null and undefined (and also falsy members like "" and 0 — be careful with numbers and strings).
  • Equality: x === "left", x !== null, and switch on a literal union.
  • `in`: "swim" in animal keeps the members that have (or may have) a swim property.
  • `instanceof`: err instanceof Error narrows to the class type.
  • `Array.isArray(x)` narrows to the array member.

Narrowing works with early exits too: after if (typeof x === "string") return;, the rest of the function knows x is a number. Assignments narrow as well — after x = 5 the checker knows x is a number until it changes again.

The checker narrows variables and parameters, not arbitrary expressions. Narrowing of a property like obj.value is lost after a function call that might have changed it, and narrowing inside callbacks may be reset for let variables because the callback could run later. Copying to a const first (const v = obj.value) usually fixes such cases.

When narrowing can't express what you need, you can write your own checks as type predicates — a later lesson.

Example

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; w: number; h: number };

function format(value: string | number | Date | null): string {
  if (value === null) return "-";
  if (typeof value === "number") return value.toFixed(2);   // number
  if (value instanceof Date) return value.toISOString();     // Date
  return value.trim();                                       // string
}

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;  // circle member
    case "rect": return s.w * s.h;                    // rect member
  }
}

function speak(pet: { bark(): string } | { meow(): string }): string {
  return "bark" in pet ? pet.bark() : pet.meow();
}

console.log(format(3.14159), format(" hi "), format(null), area({ kind: "rect", w: 2, h: 3 }));
console.log(speak({ meow: () => "meow" }));

Remember

  • Checks like typeof, ===, in, instanceof narrow unions automatically
  • Early returns narrow the rest of the function
  • typeof x === "object" does not exclude null

Watch out for

  • Using a truthiness check on number | undefined and accidentally treating 0 as missing.

Check yourself

  1. x is string[] | string | null. After if (typeof x === "object") { ... }, what is x inside the block?
  2. Which check narrows err (typed unknown) so that err.message is allowed?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/2/narrowing.html