Type Predicates & Type Guards at Scale
๐ก๏ธ Type Predicates & Type Guards at Scale
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.
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.
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.
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.