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:
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" }));
meow woof
Without the pet is Cat return type, isCat would just return a boolean, and TypeScript wouldn't narrow anything.
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:
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}`); }
user #5: a@b.co
Guards with filter
Type guards also work as callbacks, which is handy for cleaning arrays:
const values = ["a", null, "b", undefined, "c"]; const clean = values.filter((v): v is string => v != null); console.log(clean.map(v => v.toUpperCase()));
[ '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:
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());
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:
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" }));
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:
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)}`); } } }
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.