Source linked

Branded Types Make Parse-Don't-Validate Work in TypeScript

cekrem.github.io@systems_wire3 hours ago·Developer Tools·2 comments

TypeScript's structural typing makes validation ephemeral. Branded types with phantom symbols preserve parse results in the type system, eliminating shotgun parsing.

typescriptparse dont validatebranded typesphantom typesstructural typingdeveloper tools

The moment isValidUser returns true, TypeScript forgets everything it learned. Every user.email might as well be "" again. That's the core problem with validation functions that return a boolean: they throw away the proof of correctness the instant they finish. Alexis King's 2019 post "Parse, don't validate" has been rattling around my head ever since I first read it, but in TypeScript the advice is harder to follow than in Haskell or Elm. Structural typing means string is always string—no newtype, no way to distinguish a validated email from an unvalidated one at the type level.

The Validator That Lied to You

Here's the pattern I see everywhere, including in code I wrote last week:

interface User {
 id: number;
 email: string;
 age: number;
}

function isValidUser(user: User): boolean {
 if (!user.email.includes("@")) return false;
 if (user.age < 0 || user.age > 150) return false;
 return true;
}

function sendWelcome(user: User) {
 if (!isValidUser(user)) throw new Error("invalid user");
 // three function calls later:
 emailService.send(user.email, `Welcome, age ${user.age}`);
}

Spot the lie. User.email is string. User.age is number. The validation happened, but the type system forgot it instantly. Nothing stops someone from passing user.email to a function expecting a real email, because as far as TypeScript is concerned it's just a string. The result? Scattered re-validation—what King calls "shotgun parsing"—and the creeping feeling that your type system is just a linter with extra steps.

Branded Types: Lying to the Compiler on Purpose

TypeScript doesn't have newtype, but it has phantom symbols. The trick is to declare a unique symbol that never exists at runtime and intersect it with the base type:

declare const EmailBrand: unique symbol;
type Email = string & { readonly [EmailBrand]: true };

Email is still a string structurally—it can be passed anywhere a string is expected. But no one outside this module can even name EmailBrand, so string cannot be assigned to Email without an explicit cast. You've made the domain nominal on the way in and structural on the way out, which is exactly what you want.

The parser then returns a discriminated union:

type Parsed<T> = { kind: "ok"; value: T } | { kind: "err"; error: ParseError };

function parseEmail(raw: string): Parsed<Email> {
 if (!raw.includes("@")) {
 return { kind: "err", error: { kind: "ParseError", message: "missing @" } };
 }
 return { kind: "ok", value: raw as Email };
}

No runtime overhead—the brand field exists only at compile time. The as Email cast is the one acceptable lie: you've already checked the invariant, and now you're encoding it in the type. Downstream, if someone tries to call sendWelcome with an unparsed User, the compiler tells them to kick rocks.

What This Enables

Once you parse an Email out of a raw string, the rest of the program never has to re-validate. No if (user.email) checks scattered through service layers. No defensive as Email casts that bypass the parser. The type itself serves as the proof of validity, and the compiler enforces it. Next time you reach for a boolean validator, ask yourself: can I write a parser that preserves what I learned? In TypeScript, branded types make that answer a clear yes—without leaving the language or reaching for a runtime validation library.


Source: Parse, Don't Validate - In a Language That Doesn't Want You To
Domain: cekrem.github.io

Read original source ->

External source stays available while the OJO article and comment thread stay local.

Comments load interactively on the live page.