Type Predicates & Type Guards at Scale

Advanced TypeScript — Type Predicates & Type Guards at Scale
Advanced TypeScript
Course 4 · Chapter 4 · Type Predicates & Type Guards at Scale

🛡️ Type Predicates & Type Guards at Scale

Course 2's isDog(pet): pet is Dog works fine for one union in one file. This chapter is about what happens when a codebase has dozens of unions to narrow — reusable guard factories, guards that combine with and/or, and assertion functions, so narrowing logic stops being copy-pasted everywhere it's needed.

Recap: The Three Kinds of Guard

typeof / instanceof

Built into the language, work automatically — no is syntax needed for primitives and class instances.

Custom value is Type

A function you write for shapes the built-ins can't check — the pattern Course 2 introduced for Dog vs Cat.

The gap this chapter fills: writing one narrow guard per union, per file, does not scale to a codebase with fifty discriminated unions.

🏭 Reusable Guard Factories

Instead of a bespoke guard for every object shape, a generic factory can check "does this object have property K" once, and be reused everywhere:

function hasProperty<K extends PropertyKey>( obj: unknown, key: K ): obj is Record<K, unknown> { return typeof obj === "object" && obj !== null && key in obj; } function handle(value: unknown) { if (hasProperty(value, "email")) { console.log(value.email); // ✅ TypeScript knows `email` exists (typed as unknown) } }

This one function replaces a whole family of one-off "does this have an email field" / "does this have a total field" checks that would otherwise be duplicated per union.

Combining Guards With and / or

Real checks are often several conditions at once — combinators let you build a compound guard out of smaller, reusable ones instead of writing a new function every time:

Generic Guard Combinators

function and<A, B extends A>( guardA: (value: A) => value is B, guardB: (value: B) => boolean ) { return (value: A): value is B => guardA(value) && guardB(value as B); } function or<A, B, C>( guardA: (value: A) => value is B, guardB: (value: A) => value is C ) { return (value: A): value is B | C => guardA(value) || guardB(value); } // Usage: "is a non-empty string" function isString(v: unknown): v is string { return typeof v === "string"; } const isNonEmptyString = and(isString, (v) => v.length > 0); isNonEmptyString("hi"); // true, and `v` narrows to string inside isNonEmptyString(""); // false

and

Both guards must pass — narrows to the more specific of the two, useful for "is a string AND non-empty" style checks.

or

Either guard passing is enough — narrows to the union B | C, useful for "is a Dog OR a Cat" style checks without a discriminant.

A Guard Factory for Discriminated Unions

Chapter 3's discriminated unions (ApiResponse<T>, ClientMessage) all follow the same shape — a factory can generate their guards instead of hand-writing a switch for each one:

function isVariant<T extends { type: string }, K extends T["type"]>( value: T, type: K ): value is Extract<T, { type: K }> { return value.type === type; } type ClientMessage = | { type: "move"; x: number; y: number } | { type: "chat"; text: string }; function handleMessage(message: ClientMessage) { if (isVariant(message, "move")) { console.log(message.x, message.y); // ✅ narrowed via Extract<T, {type: "move"}> } }

Extract<T, U> is a built-in conditional type (implemented with the same distributive trick from Chapter 1) — isVariant reuses it instead of writing a bespoke narrowing type per union.

✅ Assertion Functions

A type guard returns a boolean you branch on. An assertion function throws if the check fails, narrowing everything after the call instead of inside an if block:

function assertIsString(value: unknown): asserts value is string { if (typeof value !== "string") { throw new Error("Expected a string"); } } function processInput(value: unknown) { assertIsString(value); console.log(value.toUpperCase()); // ✅ narrowed to string for the REST of this function, no `if` needed } // A generic assertion combinator, mirroring `hasProperty` above: function assertDefined<T>(value: T, message = "Value was null or undefined"): asserts value is NonNullable<T> { if (value === null || value === undefined) { throw new Error(message); } }

asserts value is T

Narrows value to T for every line after the call — no if wrapper required, at the cost of throwing instead of returning false.

asserts condition

A simpler form with no specific type — just tells the compiler "if we got past this line, some earlier possibility is now ruled out" (e.g. narrowing away undefined).

💻 Coding Challenges

Challenge 1: Build a Generic hasProperties

Generalize the chapter's hasProperty to hasProperties<K extends PropertyKey>(obj: unknown, ...keys: K[]), checking that all given keys exist on the object.

Goal: Practice extending a single-key guard to a variadic, multi-key one using the tuple/rest patterns from Chapter 3.

→ Solution

Challenge 2: Write an isOneOf Guard

Write a generic isOneOf<T extends readonly unknown[]>(value: unknown, options: T): value is T[number] that checks whether value matches any element of a fixed array of allowed values.

Goal: Practice a reusable guard for "is this one of these specific literal values" — the enum-like check pattern used constantly for validating string literals.

→ Solution

Challenge 3: Write an Assertion Function

Write assertIsRecord(value: unknown): asserts value is Record<string, unknown>, then use it at the top of a function to avoid repeating null/type checks on every subsequent property access.

Goal: Practice the asserts value is T pattern as an alternative to an if-wrapped type guard.

→ Solution

⚠️ Gotcha: A Guard That Lies

function isString(v: unknown): v is string { return true; } compiles perfectly and will happily narrow anything to string — TypeScript trusts a type predicate's return type completely and performs zero runtime verification of its own. A type guard is only as safe as the check actually inside its body; a guard reused across dozens of call sites that's subtly wrong (or, worse, a stub left over from early development) is a much bigger blast radius than a single hand-written check that was wrong in one place.

🎯 What's Next

Guards prove what a value is at runtime — the next chapter covers proving what a value means, even when two values share the exact same runtime representation: Branded Types & Phantom Types, encoding domain constraints (like "this string was validated" or "this number is a UserId, not an OrderId") directly into the type system.