AniUI Academy

Narrowing

Turn a union into one specific type inside a branch using typeof, instanceof, in, truthiness, and discriminated unions — the core skill of working with TypeScript day to day.

11 min read

A union type describes what a value might be. Narrowing is how you find out, inside a specific branch of code, exactly which one it is — and it's the single most-used skill in everyday TypeScript, because almost nothing useful happens with a union until you've narrowed it.

typeof narrowing

For primitives, a typeof check does double duty — it's the same runtime check JavaScript always had, and TypeScript reads it to narrow the type:

function describe(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase(); // value is string here
  }
  return value.toFixed(2);      // value is number here — the only option left
}

No annotation, no cast, nothing TypeScript-specific to learn beyond the typeof check you'd already write in plain JavaScript. TypeScript's control flow analysis follows the branches and narrows automatically.

instanceof narrowing

For classes, instanceof narrows the same way typeof does for primitives — genuinely important for handling errors, since a caught value in TypeScript is typed unknown (covered in depth soon), and JavaScript permits throwing any value at all, not just Error instances:

try {
  riskyOperation();
} catch (error) {
  if (error instanceof Error) {
    console.log(error.message); // safe — Error definitely has .message
  } else {
    console.log("Something was thrown that wasn't an Error:", error);
  }
}

in narrowing

The in operator checks whether a property exists on an object at runtime, and TypeScript narrows a union down to whichever members actually declare that property:

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
 
function area(shape: Circle | Square) {
  if ("radius" in shape) {
    return Math.PI * shape.radius ** 2; // shape is Circle here
  }
  return shape.side ** 2;               // shape is Square here
}

Discriminated unions

The pattern above works, but there's a more direct and far more common version: give every member of the union one shared property holding a distinct literal value (a "discriminant" or "tag"), and switch on that directly:

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
 
function area(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // shape is Circle here
    case "square":
      return shape.side ** 2;             // shape is Square here
  }
}

TypeScript specifically recognises this shape — one property, one literal type per union member — and narrows on every comparison against it, whether that's a switch, an if, or ===. This is the pattern behind type: "loading" | "success" | "error" state objects you'll see constantly in real applications, and it's worth reaching for deliberately rather than discovering it by accident.

Truthiness narrowing

A plain if (value) narrows out falsy values (undefined, null, "", 0, NaN, false) the same way it does at runtime:

function greet(name: string | undefined) {
  if (!name) {
    return "Hello, stranger";
  }
  return `Hello, ${name}`; // name is string here
}

Be careful with this one on numbers: if (count) also excludes 0, which is usually not what you mean if 0 is a legitimate, meaningful value rather than an absence.

Exhaustiveness checking with never

A real benefit of discriminated unions: if you later add a new shape to the union and forget to handle it somewhere, TypeScript can catch that at compile time, using the fact that never accepts no values at all:

type Shape = Circle | Square; // imagine a Triangle is added here later
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default: {
      const exhaustive: never = shape; // errors if a case was missed
      throw new Error(`Unhandled shape: ${exhaustive}`);
    }
  }
}

If a Triangle member is added to Shape later and nobody updates this function, shape inside default is no longer narrowed down to nothing (never) — it's still Triangle — and assigning it to a variable typed never becomes a compile error, right where the new case needs handling.

Try it yourself

Try it yourself
Loading playground...

What to remember

  • typeof, instanceof, and in all narrow a union down to a subset of its members, following the same runtime checks JavaScript already has.
  • A discriminated union (a shared literal "tag" property across every member) is the most common and most reliable pattern for modelling "one of several shapes."
  • Truthiness narrowing excludes falsy values, but treat if (count) carefully when 0 is a meaningful value.
  • Assigning to a variable typed never in a default branch catches missing cases at compile time as a union grows.

Check yourself

4 questions · pass 3/4 to unlock Arrays, Tuples, and Enums

up to 50
  1. 1.Inside if (typeof value === "string") { ... }, what is the type of value within that block, given value: string | number?

  2. 2.Which check correctly narrows error: unknown before reading error.message?

  3. 3.For a discriminated union like { kind: "circle"; radius: number } | { kind: "square"; side: number }, what narrows it correctly?

  4. 4.What does the in operator check narrow on, e.g. if ("radius" in shape)?

4 left to answer