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
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
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.
(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.
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.
// 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.
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`?
function isNumber(x: unknown): x is number {
return typeof x === "number";
}
function f(x: string | number) {
if (!isNumber(x)) {
const y = x;
// ^?
}
}what is the type of `y`?
function assertDefined<T>(x: T | undefined): asserts x is T {
if (x === undefined) throw new Error("expected value");
}
function f(x: string | undefined) {
assertDefined(x);
const y = x;
// ^?
}what is the type of `y`?
function isFoo(x: unknown): x is { foo: string } {
return true;
}
function f(x: unknown) {
if (isFoo(x)) {
const v = x.foo;
// ^?
}
}what is the type of `v` at compile time? what happens at runtime if `x` is `42`?
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}`);
}// 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.
type Item = { v: number } | null | undefined;
function present<T>(x: T): x is NonNullable<T> {
return x !== null && x !== undefined;
}
const items: Item[] = [{ v: 1 }, null, { v: 2 }, undefined];
const live = items.filter(present);
// ^?// live: { v: number }[]
//
// without the predicate, filter
// returns the same Item[] — TS can't
// tell that the truthy check removed
// null and undefined.the one place you'll write predicates in everyday code. filtering arrays without a predicate keeps the original union; with one, you get the narrowed array.
type EventA = { kind: "user_created"; id: string };
type EventB = { kind: "order_paid"; orderId: string };
type Event = EventA | EventB;
function isEventA(e: unknown): e is EventA {
return typeof e === "object" && e !== null
&& (e as any).kind === "user_created"
&& typeof (e as any).id === "string";
}
queue.on("message", (raw: unknown) => {
if (isEventA(raw)) handleA(raw);
});// inside the handler:
// raw: { kind: "user_created"; id: string }
//
// boundary in: unknown.
// boundary out: typed event.
// internal code below: never re-validates.wire protocols are boundaries. predicates (or schemas that generate them) belong at the parse step, not scattered through business logic.
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.
// 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.
what you took away
-
value is Tis the predicate;asserts value is Tis the assertion. one narrows in branches; the other narrows past the call site. - the compiler trusts the predicate's claim — it doesn't check the body. a lying predicate is a typed bug. generate predicates from a schema (zod, valibot, ajv) to keep type and runtime in sync.
-
Array.prototype.filterwith a predicate is the one place predicates show up in everyday application code, because it's the only standard-library function whose return type can be narrowed via the predicate. - use predicates at the boundary (http, queue, storage, env). use discriminated unions everywhere else. a predicate inside the app usually means the type was wrong upstream.
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
- typescript handbook · type predicates — short reference, including
asserts. - zod — the canonical predicate generator. schema as source of truth.
- /posts/ · illegal states unrepresentable — the design principle these six lessons are tools for.