Type narrowing cheatsheet.

typeof

function f(x: string | number) {
  if (typeof x === "string") x.toUpperCase();    // string
  else x.toFixed();                              // number
}

typeof returns: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function".

instanceof

function fail(err: unknown) {
  if (err instanceof Error) {
    err.message;
    err.stack;
  }
}

Works for classes and built-ins. Doesn’t work across realms (iframes).

in operator

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

function speak(a: Cat | Dog) {
  if ("bark" in a) a.bark();
  else a.meow();
}

Discriminated unions

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "square"; size: number }
  | { kind: "rect"; w: number; h: number };

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

The literal kind field is the discriminator. TS narrows on it.

Exhaustive check (never)

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "square": return s.size ** 2;
    case "rect": return s.w * s.h;
    default: {
      const _exhaustive: never = s;
      throw new Error(`unreachable: ${_exhaustive}`);
    }
  }
}

Adding a new variant triggers a compile error. Worth doing.

User-defined type guards

function isString(x: unknown): x is string {
  return typeof x === "string";
}

function isUser(x: unknown): x is User {
  return (
    typeof x === "object" &&
    x !== null &&
    "id" in x && typeof (x as any).id === "number"
  );
}

Predicate functions narrow on call.

Assertion functions

function assert(cond: unknown, msg?: string): asserts cond {
  if (!cond) throw new Error(msg);
}

function assertIsString(x: unknown): asserts x is string {
  if (typeof x !== "string") throw new TypeError("not string");
}

// Use
function f(x: unknown) {
  assertIsString(x);
  x.toUpperCase();        // x is string after assertion
}

asserts cond narrows cond; asserts x is T narrows x to T.

Truthiness narrowing

function f(s: string | null) {
  if (s) s.toUpperCase();      // string (not null, not "")
}

Watch for 0, "", false if those are valid values.

Equality narrowing

function f(x: string | number, y: string | boolean) {
  if (x === y) {
    // both narrowed to string
    x.toUpperCase();
    y.toUpperCase();
  }
}

Control flow narrowing

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

Early returns narrow downstream.

Type predicate vs assertion

// Predicate: returns boolean, narrows
function isUser(x: unknown): x is User { ... }
if (isUser(x)) x.id;

// Assertion: throws, narrows after call
function assertUser(x: unknown): asserts x is User { ... }
assertUser(x);
x.id;

Tagged narrowing for Promises

type Result<T> =
  | { status: "ok"; value: T }
  | { status: "loading" }
  | { status: "error"; error: string };

function render(r: Result<string>) {
  switch (r.status) {
    case "ok": return r.value;
    case "loading": return "...";
    case "error": return r.error;
  }
}

Narrowing arrays

const xs: (string | number)[] = [];

if (xs.every(x => typeof x === "string")) {
  // xs still (string | number)[] — `every` doesn't narrow
}

// Use a type predicate:
function allStrings(arr: unknown[]): arr is string[] {
  return arr.every(x => typeof x === "string");
}

if (allStrings(xs)) {
  xs.map(s => s.toUpperCase());
}

Object property narrowing

function f(x: { a?: string }) {
  if (x.a) x.a.toUpperCase();   // narrowed
  
  // But function call resets:
  function inner() { x.a.toUpperCase(); }  // ERROR
}

Reassign locally:

const a = x.a;
if (a) {
  function inner() { a.toUpperCase(); }    // ok
}

Const assertion + narrowing

function f(x: "a" | "b") { ... }

const k = "a";              // type: "a"
f(k);                       // ok

let k2 = "a";               // type: string — no good!
f(k2);                      // ERROR

let k3 = "a" as const;      // type: "a"
f(k3);                      // ok

Branded types (nominal)

type UserId = string & { __brand: "UserId" };

function makeUserId(s: string): UserId {
  return s as UserId;
}

function getUser(id: UserId) { ... }

const raw = "abc";
getUser(raw);              // ERROR
getUser(makeUserId(raw));  // ok

Forces explicit construction.

Common mistakes

  • typeof x === "object" doesn’t exclude null (typeof null === “object”).
  • Forgetting exhaustive check — silent breakage when union grows.
  • Type guard without proper runtime check — lying to the compiler.
  • in narrows but doesn’t guarantee the value type — still need to validate.
  • Reassigning narrowed values — narrowing is lost.

Read this next

If you want my type-guard library, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .