Parse, Don't Validate — In a Language That Doesn't Want You To · cekrem.github.ioUpdate: If you liked this post, the follow-up — Effect Without Effect-TS: Algebraic Thinking in Plain TypeScript — picks up where we left off and takes the ideas further.<br>I’ve been thinking about Alexis King’s Parse, don’t validate again. I do this quite regularly, actually, usually after staring at a TypeScript codebase that’s been quietly accumulating if (user.email) checks like barnacles. The post is from 2019, and the advice (or rather principle) is way older than that. And yet most TypeScript I read — including, embarrassingly, plenty I’ve written — still validates instead of parsing.<br>The pitch, if you haven’t read it (you should): a validator says “this thing is fine, please continue.” A parser says “give me a blob, and I’ll either give you back a more precise type or tell you why I can’t.” The difference sounds academic until you realize that validators throw away information the moment they finish running, while parsers preserve what they learned by encoding it in the type. Once you’ve parsed a string into an EmailAddress, the rest of your program never has to wonder again. Peace of mind and more mental capacity for the fun stuff.<br>In Haskell or Elm or F# this is just how you write code. The language pulls you toward it. In TypeScript… it doesn’t. TypeScript will happily let you do the right thing, but it won’t insist, and it won’t even gently nudge. If anything, structural typing actively undermines the whole game.<br>Let me show you what I mean.<br>The validator we’ve all written
Link to heading<br>Here’s the kind of code I see (and write) constantly:<br>interface User {<br>id: number;<br>email: string;<br>age: number;
// The actual validation is naîve and simplistic, but you get the point:<br>function isValidUser(user: User): boolean {<br>if (!user.email.includes("@")) return false;<br>if (user.age 0 || user.age > 150) return false;<br>return true;
function sendWelcome(user: User) {<br>if (!isValidUser(user)) {<br>throw new Error("invalid user");<br>// ...later, deeper in the call stack:<br>emailService.send(user.email, `Welcome, age ${user.age}`);
Spot the lie? User.email is just string. User.age is just number. The validation happened — congrats — but the type system forgot about it the instant isValidUser returned. Three function calls deeper, when somebody touches user.email, there is nothing stopping them from passing it to a function that expects a real email. Because as far as TypeScript is concerned, it’s just a string. Same as "", same as "hello", same as "definitely not an email".<br>So what do we do? We re-validate. We add another if. We write a unit test. We hope. (King has a much better word for this in the original post: “shotgun parsing” — validation scattered everywhere, none of it remembered.)<br>What we actually want
Link to heading<br>We want this:<br>function sendWelcome(user: ValidUser) {<br>emailService.send(user.email, `Welcome, age ${user.age}`);
And we want it to be impossible to call sendWelcome with anything that hasn’t been through the parser. No re-checking or “defensive programming”. The type itself serves as the proof, as it were.<br>In Elm I’d reach for an opaque type and a smart constructor and be done in about four lines. In TypeScript it’s, well, possible at least. Just less pleasant.<br>Branded types, or: lying to the structural type system on purpose
Link to heading<br>TypeScript is structurally typed, which means two types with the same shape are the same type. string is string is string. There’s no newtype. There’s no type EmailAddress = String that produces a genuinely distinct type the way, say, Haskell does it.<br>The workaround the community has settled on is branding — also called tagging, also called nominal typing via intersection. The cheap version is a string-literal phantom ({ readonly __brand: "Email" }) and you’ll see it everywhere; the slightly less cheap version uses a unique symbol that you don’t export from the module, so nobody outside can even spell the brand to forge it:<br>declare const EmailBrand: unique symbol;<br>declare const AgeBrand: unique symbol;
type Email = string & { readonly [EmailBrand]: true };<br>type Age = number & { readonly [AgeBrand]: true };
There is no brand field at runtime. It’s a “phantom” — a type-level marker that makes Email and string incompatible at compile time. The only way to get an Email is through a function that knows how, because nothing outside this module can even name the symbol to fake one. (TS5 also lets you flirt with template literal types — type Email = `${string}@${string}` — which is fun for a demo and not enough on its own.) This is the move that lets you make illegal states unrepresentable without leaving the language.<br>The brand is one-way, by the way: an Email is still assignable to...