Parse, Don't Validate — a TypeScript Field Guide
Alexis King’s essay “Parse, Don’t Validate” is usually filed under Haskell, but the discipline ports cleanly to TypeScript: check your data once, at the boundary, and let the type system remember the check happened. A validator returns a boolean and throws away what it learned; a parser returns a narrower type that makes the re-check unnecessary — and the illegal state unrepresentable.
The validator trap
Here is the shape of the problem. This function “works,” and every caller pays for it forever:
function isValidEmail(s: string): boolean {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
}
function sendWelcome(email: string) {
// Is this validated? Who knows! Better check again...
if (!isValidEmail(email)) throw new Error("invalid email");
// ...
}
The type of email is string before the check and string after it. The
knowledge evaporates, so every layer re-validates defensively — or worse,
trusts blindly.
Branded types: making the check stick
TypeScript has no nominal types, but an intersection with a phantom property gets you there:
type Email = string & { readonly __brand: "Email" };
function parseEmail(s: string): Email {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) {
throw new Error(`not an email: ${s}`);
}
return s as Email;
}
// The only way to obtain an Email is through the parser,
// so this signature is a *proof requirement*, not a hint:
function sendWelcome(email: Email) {
/* no re-check needed, ever */
}
Now sendWelcome("bob") is a compile error. The as Email cast appears in
exactly one place — the parser — which is the whole point: one auditable
boundary instead of a hundred scattered ifs.
Discriminated unions: parsing structure, not just strings
The same move works on shapes. Don’t model a payment as one bag of optional fields; parse it into a union whose variants are internally consistent:
type Payment =
| { kind: "card"; last4: string; expiry: string }
| { kind: "upi"; vpa: string }
| { kind: "netbanking"; bankCode: string };
function settle(p: Payment) {
switch (p.kind) {
case "card":
return chargeCard(p.last4, p.expiry); // p.vpa doesn't exist here
case "upi":
return collectUpi(p.vpa);
case "netbanking":
return redirect(p.bankCode);
}
}
The switch is exhaustive: add a fourth variant and the compiler walks you
to every site that must handle it.
Zod: the parser you don’t have to write
At real boundaries — HTTP bodies, env vars, frontmatter — hand-rolled parsers
get tedious. Zod is the standard answer, and it follows the doctrine exactly:
parse returns the narrowed type or throws.
import { z } from "zod";
const Signup = z.object({
email: z.string().email().brand<"Email">(),
age: z.coerce.number().int().min(13),
plan: z.enum(["free", "pro"]).default("free"),
});
type Signup = z.infer<typeof Signup>;
export function handleSignup(body: unknown): Signup {
return Signup.parse(body); // one boundary, typed forever after
}
Note the input type: unknown, not any. unknown is honest — it forces
the parse — while any is a promise you can’t keep.
This blog eats its own cooking, incidentally: every post’s frontmatter is parsed by a Zod schema at build time, so a typo’d category is a build failure, not a silently broken archive page.
The one-sentence version
Push every check to the edge, return evidence instead of booleans, and make the middle of your program a place where bad data cannot exist — then delete the defensive checks the types just made unnecessary.
Comments