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.