typescript · level 6

Type Guards & Narrowing

typeof, instanceof, `in`, predicates, assertions — and which to reach for.

175 XP

Type Guards & Narrowing

Narrowing is what makes TypeScript usable. Without it, every union would force you to write (x as Whatever) everywhere — defeating the point. With it, you check the type at runtime and TypeScript follows along, narrowing the static type as you go.

What "narrowing" means

You start with a wide type:

function f(x: string | number) {
  // x is string | number here
}

You write a check at runtime:

if (typeof x === "string") {
  // x is string here — TS narrowed it
} else {
  // x is number here — TS narrowed it the OTHER way
}

The else branch is half the magic — TS removes string from the union once you've checked for it. This is control-flow analysis: TS reads your code top-to-bottom and updates the type at every assignment, condition, and early-return.

The five built-in guards

1. typeof — primitives

function f(x: string | number | boolean) {
  if (typeof x === "string") /* x: string */;
  if (typeof x === "number") /* x: number */;
  if (typeof x === "boolean") /* x: boolean */;
}

Works for "string" | "number" | "boolean" | "symbol" | "bigint" | "undefined" | "object" | "function". Note: typeof null === "object" (a JS quirk TS faithfully reproduces).

2. instanceof — class instances

function f(d: Date | string) {
  if (d instanceof Date) return d.toISOString();
  return d;   // d: string
}

Works for any class. Subclasses also pass instanceof of their parent.

3. in — property presence on object types

type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(a: Cat | Dog) {
  if ("meow" in a) a.meow();   // a: Cat
  else a.bark();                // a: Dog
}

Excellent for distinguishing object shapes that don't share a discriminator field.

4. Equality

Direct comparison narrows on literal types:

function f(x: 1 | 2 | 3) {
  if (x === 2) /* x: 2 */;
  else /* x: 1 | 3 */;
}

This is also how discriminated unions work — if (s.kind === "circle") narrows on the discriminator.

5. Truthy/falsy

function f(x: string | undefined) {
  if (x) /* x: string */ ;
}

A null-or-empty-string check that narrows. Be careful with numbers: if (x) excludes 0, so x: 0 | 1 | 2 narrows to 1 | 2 inside the truthy branch — usually a bug.

Custom type predicates: x is T

When the built-ins don't express your check, write a function that returns a type predicate:

interface User { id: number; name: string }

function isUser(x: unknown): x is User {
  return (
    typeof x === "object" && x !== null &&
    "id" in x && typeof (x as Record<string, unknown>).id === "number" &&
    "name" in x && typeof (x as Record<string, unknown>).name === "string"
  );
}

function handle(raw: unknown) {
  if (isUser(raw)) {
    raw.name;   // raw: User
  }
}

The return type x is User does two things:

  1. The return value is a boolean (so the caller can use it in if).
  2. When that boolean is true, TS knows x is a User in the calling scope.

You're making a promise to TypeScript. If your predicate returns true for a value that isn't actually a User, you've broken the type system at runtime — and TS won't catch it. Predicates are unsound by definition; treat them like the assertions they are.

Assertion functions: asserts x is T

Same idea, but for functions that throw on failure rather than return false:

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

function shout(input: unknown) {
  assertString(input);
  return input.toUpperCase();   // input: string after the call
}

After assertString(input) returns (didn't throw), TS narrows input to string for the rest of the function. This is the pattern behind node:assert, invariant, runtime schema validators with .parse() (zod), and so on.

A common pitfall: arrays and objects

typeof doesn't distinguish arrays from objects:

function f(x: string[] | { a: string }) {
  if (typeof x === "object") /* x is BOTH — useless */;
  if (Array.isArray(x)) /* x: string[] — works! */;
}

Array.isArray is the special-cased guard for arrays. TS knows about it and narrows correctly.

Combining guards with the ! postfix

The ! non-null assertion isn't a guard — it's a promise that the value isn't null/undefined. TS believes you and narrows accordingly. Use sparingly, only when you know more than TS:

const el = document.getElementById("root")!;   // promise: not null
el.innerHTML = "...";

If you're wrong, you crash at runtime. Prefer a real check (if (el) { ... }) when feasible.

Narrowing with discriminated unions

This is the single best use of narrowing — covered fully in the discriminated-unions lesson, but a quick reminder:

type State =
  | { kind: "loading" }
  | { kind: "success"; data: User[] }
  | { kind: "error"; error: Error };

function render(s: State) {
  if (s.kind === "loading") return <Spinner />;
  if (s.kind === "error") return <Banner err={s.error} />;
  return <List users={s.data} />;
}

The discriminator field's literal type makes equality narrow each branch into a single variant.

When to write a custom predicate

Reach for one when:

  • You have a runtime check that the built-in guards can't express (parsing JSON, validating an API response).
  • The check is reused in 3+ places — the predicate becomes the canonical "is this a User".

Don't reach for one when:

  • A built-in guard works. typeof / instanceof / in / equality cover 80% of cases without the predicate footgun.
  • The check is one-off — inlining it is clearer than naming a function whose body is one line.

When to use an assertion function

When the alternative is a chain of if (!isUser(x)) throw …. Assertion functions read better and remove the boolean-handling boilerplate. Most validation libraries (zod.parse, superstruct, arktype) provide them out of the box.

That's the whole game: narrow the type as you check the value, and trust TypeScript to follow along.

Tools in the wild

3 tools
  • zodfree tier

    Runtime schemas that double as TypeScript guards via `.safeParse` + inferred types.

    library
  • valibotfree tier

    Smaller, tree-shakeable alternative to zod with the same TypeScript-first ergonomics.

    library
  • Hover variables inside each branch — TS shows the narrowed type instantly.

    service