Type Narrowing & Type Guards

intermediate narrowing type-guards typeof instanceof in

Narrowing is how TypeScript moves from a broad type (like string | number) to a specific one inside a branch of code. It’s TS’s secret sauce — the compiler reads our control flow and adjusts the type at each step.

In simple language, the compiler runs through our if/else and updates what it thinks the type is, the same way our brain does when reading the code.

typeof — for primitives

The classic. TS recognizes typeof x === "string" | "number" | "boolean" | "undefined" | "object" | "function" | "bigint" | "symbol".

function double(x: string | number): string | number {
  if (typeof x === "number") {
    return x * 2;          // x is number here
  }
  return x.repeat(2);      // x is string here
}

instanceof — for class instances

class Dog { bark() { console.log("woof"); } }
class Cat { meow() { console.log("meow"); } }

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

in operator — check property existence

Great for narrowing between object types without a discriminator.

type Bird = { fly: () => void };
type Fish = { swim: () => void };

function move(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly();   // Bird
  } else {
    animal.swim();  // Fish
  }
}

Equality narrowing

Comparing to a literal narrows.

function f(x: string | null) {
  if (x === null) return;
  x.toUpperCase(); // x is string here
}

This is also how discriminated unions narrow on a kind/type field.

Truthiness narrowing

if (value) narrows away null, undefined, "", 0, false.

function f(x: string | null | undefined) {
  if (x) {
    x.toUpperCase(); // narrowed to string
  }
}

Be careful — empty string and 0 are falsy. If we mean “not null/undefined”, use x != null (loose equality on purpose — catches both).

User-defined type guards (type predicates)

When the built-in narrowing isn’t enough, we can write our own. A type predicate is a function that returns arg is SomeType.

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

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

The signature animal is Cat is a promise to the compiler: “if I return true, treat the argument as Cat from here on”. TS trusts us — make sure the runtime check actually matches.

Assertion functions

Cousin of type predicates. Throws if the check fails, narrows on the rest of the function after the call.

function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== "string") throw new Error("Not a string");
}

function f(input: unknown) {
  assertIsString(input);
  input.toUpperCase(); // narrowed to string here on
}

Discriminated unions

The cleanest narrowing pattern — a shared literal field that uniquely identifies each variant. Big enough topic to have its own note.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

function area(s: Shape): number {
  if (s.kind === "circle") return Math.PI * s.radius ** 2;
  return s.size ** 2;
}

never for exhaustiveness

A handy pattern — in the default branch of a switch, assign to never. If a new variant is added later, TS errors here.

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.size ** 2;
    default:
      const _exhaustive: never = s; // ❌ if Shape grows
      return _exhaustive;
  }
}

Interview soundbite

“Narrowing is TS reading our control flow and shrinking a broad type inside branches. Built-in tools: typeof, instanceof, in, equality, truthiness. For custom checks we write user-defined type guards returning arg is Type. The pattern matters because it’s how we work safely with union types.”