scalable/craft/ts types in anger/lesson 06 cph / /
lesson 06 / 06 · 10 min · updated ·

type predicates at the boundary

user-defined guards and assertion functions. where they fit (the boundary). where they're a smell (everywhere else).

the boundary problem

everything we've built so far — discriminated unions, brands, template literals, conditional and mapped types — assumes the values coming in already have the right shape. inside the app, that's true: types compose, callers know what they're passing. but at the boundary — http requests, message queues, local storage, environment variables, anything you didn't author — values arrive as unknown. the type system can't help until you convince it the data fits.

type predicates are the convincer. they're functions whose return type is a type assertion: a promise to the compiler that if the function returns true, the argument has a specific type.

before predicates: cast every time

without a predicate
function isUser(value: unknown): boolean {
  return typeof value === "object"
    && value !== null
    && "id" in value
    && typeof (value as any).id === "string";
}

function greet(value: unknown) {
  if (isUser(value)) {
    // value is still unknown. the compiler doesn't trust isUser.
    console.log((value as any).id);
  }
}

we did the runtime check correctly, but the compiler doesn't see it. isUser returns boolean — that's all the type signature says. so inside the if, value is still unknown, and we have to cast it (as any) to reach the id field. every callsite of isUser pays the same cost.

after: value is User

with a predicate
interface User { id: string }

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

function greet(value: unknown) {
  if (isUser(value)) {
    // value is User here. no cast.
    console.log(value.id);
  }
}

one change: boolean becomes value is User. that return type is a type predicate — a contract that when this function returns true, the argument can be safely treated as User from then on. inside the if branch, the compiler narrows value automatically. no cast, no any.

shape a type predicate's signature is always (value: WiderType): value is NarrowerType. the narrower type must be assignable to the wider one. the function body is the runtime check; the return type is the promise to the compiler.

assertion functions

a sibling to predicates: assertion functions narrow the type after the call, not just inside an if. they throw if the assertion fails. their return type is asserts value is T.

asserts ... is
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error("expected User");
  }
}

function loadAndGreet(raw: unknown) {
  assertUser(raw);
  // raw is User from this line forward
  console.log(raw.id);
}

handy when the failure path is "stop everything" rather than "fall through to an else branch". the same trick works for invariants: asserts condition (no is) narrows nothing by itself but is the only way to tell the compiler that a console.assert or similar guard guarantees the following code.

the rule the compiler doesn't enforce

type predicates are trusted. the compiler does not verify that the body actually checks what the return type claims. a function that lies will silently undermine the type system for every caller. this is the one part of typescript where you're fully on your own.

a predicate that lies
// the predicate compiles, but its body lies.
function isUser(value: unknown): value is User {
  return true; // ← compiler trusts this
}

function trust(value: unknown) {
  if (isUser(value)) {
    value.id.toUpperCase(); // runtime: Cannot read properties of undefined
  }
}

rule: predicates are a contract you write by hand. test them. or better, generate them — see the zod pattern below — so the type and the runtime check derive from the same source.

check yourself

type predicates — 4 questions

read each snippet and pick what the compiler infers.

predicate basics
function isString(x: unknown): x is string {
  return typeof x === "string";
}

function f(x: unknown) {
  if (isString(x)) {
    const len = x.length;
    //    ^?
  }
}

what is the type of `len`?

three good homes for predicates

where predicates earn their keep

three places to write a predicate without it being a smell.

// at the request handler, parse and predicate.
import { z } from "zod";

const Body = z.object({
  email: z.email(),
  age: z.number().int().min(0),
});

type Body = z.infer<typeof Body>;

function isBody(x: unknown): x is Body {
  return Body.safeParse(x).success;
}

export async function POST(req: Request) {
  const raw = await req.json();
  if (!isBody(raw)) return new Response("bad request", { status: 400 });
  // raw is Body from here on
  return new Response(`hi ${raw.email}`);
}
tsc sees
// raw is Body (parsed)
//
// the predicate's body and the type
// come from one schema. no manual
// duplication, no drift.

the canonical use. generated predicates beat hand-written ones — the runtime and type can't disagree.

where predicates are a smell

inside the app — past the boundary — a predicate is almost always a sign that the type system is being asked to recover information you threw away earlier. the right tool is usually a discriminated union (lesson 1), where narrowing happens through a literal tag that the compiler can see without trusting any function.

predicate vs. discriminator
// inside the app, this is a smell.
function process(items: (User | Admin)[]) {
  for (const item of items) {
    if (isAdmin(item)) {
      // ...
    }
  }
}

// the discriminator should be on the type, not in a function.
// from lesson 1:
type Member = { kind: "user"; ... } | { kind: "admin"; ... };

function process(items: Member[]) {
  for (const item of items) {
    if (item.kind === "admin") {
      // narrowed by the discriminant, no predicate needed.
    }
  }
}

use predicates at the edge. inside, prefer types that narrow themselves. if you find yourself writing isUser calls in business logic, ask whether User should have been a variant of a discriminated union to begin with.

rule boundaries get predicates; internals get discriminators. cross the boundary once, validate once, trust the type forever.

what you took away

where this leaves you

the six lessons stacked together: discriminate the variants (lesson 1), brand the primitives (lesson 2), encode the strings (lesson 3), conditionally extract sub-types (lesson 4), map across keys (lesson 5), and validate at the boundary (lesson 6). that's most of what the type system has to offer for production code. the rest is taste — knowing when to deploy each tool and when to leave a type alone because the runtime is the simpler answer.

further reading

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk