Custom Type Guards & Discriminated Unions

When a built-in check like typeof isn't enough, you can write your own type guard function. And when you design unions with a shared "tag" property, TypeScript can narrow them with a single comparison — and even tell you when you've forgotten a case.

A type predicate

A function whose return type is value is SomeType is a type guard. When it returns true, TypeScript narrows the argument to that type:

TS predicate.ts
type Cat = { name: string; meow(): string };
type Dog = { name: string; bark(): string };

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

function speak(pet: Cat | Dog) {
  return isCat(pet) ? pet.meow() : pet.bark();
}

console.log(speak({ name: "Tom", meow: () => "meow" }));
console.log(speak({ name: "Rex", bark: () => "woof" }));
Output
meow
woof

Without the pet is Cat return type, isCat would just return a boolean, and TypeScript wouldn't narrow anything.

Responsibility: TypeScript trusts your predicate completely. If isCat returned true for a dog, the compiler would happily let you call meow() on it. Keep guards small and obviously correct.

Guards for unknown data

Type guards are the standard way to validate data whose type is unknown:

TS validate.ts
interface User {
  id: number;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "number" &&
    "email" in value && typeof value.email === "string"
  );
}

const raw: unknown = JSON.parse('{"id": 5, "email": "a@b.co"}');
if (isUser(raw)) {
  console.log(`user #${raw.id}: ${raw.email}`);
}
Output
user #5: a@b.co

Guards with filter

Type guards also work as callbacks, which is handy for cleaning arrays:

TS filter.ts
const values = ["a", null, "b", undefined, "c"];

const clean = values.filter((v): v is string => v != null);
console.log(clean.map(v => v.toUpperCase()));
Output
[ 'A', 'B', 'C' ]

Since TypeScript 5.5, simple filters like values.filter(v => v != null) infer the predicate automatically, so the annotation is often optional.

Assertion functions

A function declared with asserts value is T throws if the check fails. After calling it, TypeScript narrows the value for the rest of the scope:

TS asserts.ts
function assertIsString(value: unknown, name: string): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`${name} must be a string`);
  }
}

const input: unknown = "  hello ";
assertIsString(input, "input");
console.log(input.trim());
Output
hello

Discriminated unions

A discriminated (or "tagged") union is a union of object types that all share one property with a different literal value — the discriminant:

TS discriminated.ts
type Loading = { state: "loading" };
type Success = { state: "success"; data: string[] };
type Failure = { state: "error"; message: string };
type Request = Loading | Success | Failure;

function render(r: Request): string {
  switch (r.state) {
    case "loading":
      return "Loading…";
    case "success":
      return `Got ${r.data.length} items`;
    case "error":
      return `Error: ${r.message}`;
  }
}

console.log(render({ state: "success", data: ["a", "b"] }));
console.log(render({ state: "error", message: "timeout" }));
Output
Got 2 items
Error: timeout

Checking r.state narrows r to exactly one member, so data is only accessible in the success case and message only in the error case. This makes impossible states — like "loading but also has an error message" — impossible to represent.

Exhaustiveness checking with never

What happens when someone adds a new state later? With a never check in the default branch, the compiler points to every switch that doesn't handle it:

TS exhaustive.ts
type Shape =
  | { kind: "circle"; r: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.r ** 2;
    case "square":
      return s.side ** 2;
    default: {
      const unhandled: never = s;
      throw new Error(`Unknown shape: ${JSON.stringify(unhandled)}`);
    }
  }
}
tsc output
error TS2322: Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.

Once every case is handled, s in the default branch has type never and the assignment compiles. Add a "triangle" case and the error disappears. This pattern turns "I forgot to update that switch" from a runtime bug into a compile error.

Try it yourself

Try it yourself
Console output