The friction with the rest of the ecosystem is real, though. Most code out there expects you to handle errors with exceptions.
I get the impression that polymorphic return types could get in the way of JSC/V8/SpiderMonkey's JIT, but I haven't measured it and I'm not sure of the actual impact on hot and cold paths. Same for all the allocations caused by custom Option<T>/Result<T,E> implementations.
I think using Zod at the edge (with branded types and whatnot), while keeping return types as T/Promise<T> to keep a sane relationship with the ecosystem is a good middle ground.
So I just define my types and then use typescript-json-schema or similar to build a JSON Schema at build time (i.e. from an npm script) which then I use to validate input using ajv.
The only thing I do on top of that is to use annotations like "@minimum 0" (or, in the email example, "@format email") where the base types are not enough, but those simply go inside comments.
So the compiled package only has ajv as runtime dependency (which you're likely to have anyway, as it's everywhere), you're just defining regular types with some annotations on top and use a dev dependency to build you the JSON Schema. And as popular as zod is, I think JSON Schema is more of a standard and likely to stay with us longer.
I also reference those generated JSON Schemas from my OpenAPI definition, as a bonus.
The reason I've not is - say there's an optional field. Currently we call that null, probably, and check each time if it's there or not. I could instead make a type, like User and UserWithPhoneNumber. Should we be making types for each combination of present/absent fields? That can't be right.
The classic answer is to move the logic inside the domain object, or have a helper function outside the object, so you aren't constantly checking for field presence/absence, but are instead writing the logic once and calling some code.
I'm not sure in practice types can help with this. But I'd love to be proven wrong.
Suppose I have a User with some attributes like birthday, email and whether they have been verified.
in common codebase, you can see `if (user.verified_at != null)` or something along the lines, in case of parsed code I do feel like I should have types for each of them (or interfaces):
- UserWithBirthday
- VerifiedUser, UnverifiedUser
- UserWithEmail, UserWithoutEmail
(and imagine having a method which accepts user with birthday and email to send an email day before their birthday, would you create UserWithBirthdayAndEmail type?)it feels like it is going to bloat the interface space, how do you tackle this problem?
default: {
const _exhaustive: never = result;
return _exhaustive;
}
...is not how people should implement an exhaustiveness check ever! An exhaustiveness check exhausts your knowledge about the world, it should throw an exception at runtime. Just returning the non-matched case is a recipe for disaster. Do this instead: default:
((value: never) => { throw new Error(`Missing case for value: ${value}`); })(result);Zod is the acceptable middleground in my opinion. Zod will allow you to throw a schema against an object and it'll tell you "yes the result fits your schema". This is fine for most projects.
If you want to go zero-dependency, you can see how far you can get with TS's type system. Branded types are kinda cool. NewTypes are also cool, but also high maintenance. Unless you're building a library that millions depend on, it's probably not worth it.
This one barely scrapes by at what feels like 30-40% "slop": "honestly", "the one thing", etc...
...but I did learn something about "Brand" types, and have personally tried to do more of "parse don't validate" in my own code.
Recently I did this similar trick for `exec( ValidExecutable(...) )` [python], where it required tagging/washing through a private function/variable to "get" the private bit.
All the scanners tend to light up when they see "exec" at all (eg: `exec( "pandoc" )` for PDF generation), but I needed to hard code a few "expected" pandoc locations so the imaginary hackers couldn't shadow "pandoc" on a path location they controlled.
I don't speak typescript so am probably missing something obvious. but. why would you parse an email(or anything really) into a string? (or string equivalent) When parsed it will end up as a specific email object, that is, something closer to a C struct. What is the articles dance doing?
I don't think disclosing helps here. If the article wasn't obviously generated, why would that affect you ?
The only issue I have is being half-way through the article and realizing I am reading hallucinated text. If I can mark the author once, I won't see them again. This works fine for me. You could argue that disclosing would fix this issue, but the issue is not that AI was used, but that it was not curated.
What did you mean by that? You don't accept mutability or any inputs on your state of mind?
The article's dance is to avoid having extra fields that are completely unnecessary here. They want some kind of nominal email type, that is actually a string, so can be used in places where a string is needed, but when a method requires an "email" you can't use any string.
It's a pretty common pattern in functional programming and in many other languages nowadays
Because you have to build the Option/Result/whatever system yourself, and propagating and unwrapping isn't fun.
Being able to define a loose input schema at the boundary and then transform it into a shape that your program actually needs is extremely useful.
Parse and Validate are not binary choices and have nothing to do with each other. Both are useful when applied correctly to a given situation.
I felt punked by most of it. I dont see what programming languages have to do with it either. Look at swift, a language that can barely only barely parse JSON. Who cares?
The short version is: the shape of a type is inherent to the type itself, but the optionality of its members is dependent on the situation. A type system that solves this problem separates these concepts to allow for this distinction.
I _suspect_ it's possible to implement something like that in typescript but I haven't tried it myself (and I doubt it's very ergonomic).
In your instance, you could have:
type User = {
// ... rest of fields
email: {
verified: boolean,
// branded type here ensures that this string is a proper email address
value: EmailAddress,
},
birthday: Date | null,
};
In this instance, your logic with a method that accepts birthday and email has all the information it needs to make its choice.It's the same thing. In the latter case, something has validated that your NonEmpty has a first and a last element. It's all validation before you stick it in a type that asserts that the validation is guaranteed to have occurred so every function receiving it doesn't need to do it itself.
Any non-trivial use of a type system will involve making guarantees the type system itself can not actually express [1]. There's nothing wrong with saying "this is a valid email in accordance with my standards" in a type. Merely using the type system to assert "I have some sort of value in the name and host fields" is valid but a degenerate use. "struct Email { name: Name, host: Hostname }" is an even stronger use of the type system, where Name and Hostname are themselves values you can only get by passing some incoming string through a validation process. Asserting that these things exist is just the most basic check possible, but your type still permits {name: "\0\0\0\0\0\0", host: "!"}, whereas under my definition, assuming that Name and Hostname are reasonably defined, that value will not be ever be something that can be witnessed.
In fact in general, while I don't absolutely rigidly apply this, especially in smaller script-like programs, when a "string" appears in my strong types that specifically means "this has unbounded contents". It's an appropriate type for "stuff I got off a network" or "stuff a user typed". What stuff? Don't know. Haven't checked it yet. When I do it'll get a more specific type like a Username or DecodedUTF8String or something else. Thanks to people using way too many "strings" and "ints" in the world I have to constantly explain to my LLM that I want stronger types. I'm yet to find the invocation to put into my CLAUDE.md or equivalent to get it to do it right the first time consistently.
[1]: With a wistful stare into the distance acknowledging the theoretical utopia of dependent types... but it doesn't seem to be coming down from "theoretical" any time soon.
In sufficiently strong nominal type systems, I can hide the constructor for an EmailAddress type (as in: nobody can just construct an EmailAddress type). In Haskell speak, I can then export a function parseEmailAddress = rawString :: string -> EmailAddress. The function parseEmailAddress is the only place that has access to the constructor. Which means that the only way to turn a string into an EmailAddress is by calling parseEmailAddress.
Note that at runtime EmailAddress is just a string. The boundaries live in the type system, not on the value level. A structural typing system (as in TypeScript) does not enable that, it forces you to turn EmailAddress into something else than just a string.
Are you confusing Email vs EmailAddress? I think that in many cases people would prefer EmailAddress to be represented as a dumb string at runtime. But if you don't, you will easily find other examples where you have 2 structurally similar types, that you don't want to mix up.
If I could add one feature to Typescript it would be something like "as" that actually validates the result against the type system and can fail. Unfortunately, that's way, way easier said than done. It's the bad type of keyword that has unbounded runtime cost because it would have to be a runtime comparison, and there are a lot of design questions about how to write it. However, I still petulantly want it even though I can hardly define it. "zod" is pretty good but you can see how trying to add that as a "keyword" is nightmare fuel for a language-level change.
You can use Pydantic in Python and serde_derive in Rust. I assume most languages have a thing like that.
> The only thing I do on top of that is to use annotations like "@minimum 0" (or, in the email example, "@format email") where the base types are not enough, but those simply go inside comments.
class User{phone: ?PhoneNumber}
over class User{phone: ?string}.The combinatorial explosion you're picturing only shows up if you make a separate type per combination of present fields, but you don't need to. An independent optional field stays one `T | null`. You only reach for distinct types when fields are correlated and present together because they represent a state, and then it's a discriminated union on a status field, which is N states, not 2^N.
type User = { name: string; verified: boolean; email?: string; lastName: string; birthday?: string | { year: string; month: string; date: string; }}
type Birthday = Required<Pick<User, 'birthday'>>;
type UserWithBirthday = User & { birthday: Birthday }
type VerifiedUser = User & { verified: true; email: string; }
type VerifiedUserWithBirthday = User & UserWithBirthday & VerifiedUser;
const userHasBDayAndEmail = (user: User): user is VerifiedUserWithBirthday => {
if (user.email === undefined || user.birthday === undefined) {
return false
}
return true
}
Any caller of userHasBDayAndEmail knows for the rest of its nested call stack if the provided user is a User object or a VerifiedUserWithBirthday.The types are cheap to write (they're all derived) and have no runtime impact (types are erased at build/compile time) and these parsing functions are quite small to write
https://www.typescriptlang.org/play/?#code/FAFwngDgpgBAqgZyg...
Philosophically, birthday and email are not attributes of a user. If you remove a user from existence, a birthdate and email address still exist. So...
> would you create UserWithBirthdayAndEmail type
...yes, something like a `profile { user, birthday, email }` type is necessary to compose the attributes you are interested in into something where those attributes do belong together.
> it feels like it is going to bloat the interface space, how do you tackle this problem?
Like all things formal verification, increase the level of verification in your critical sections and don't sweat the non-critical sections. How impactful will it be to your business if sending a birthday email message fails?
fn send_birthday_mail(user: {u: User, u.birthday != null})
Contracts are a similar solution that restricts the predicates to only appearing in function types.The difference between this and an assert is that it gets checked at compile time (it can get quite expensive to do the check though).
What can you do in mainstream languages? As much as is worth and no more than that. String -> User is worth it, User -> UserWithBirthday is not.
Luckily, F# has type providers, which lets the compiler construct nominal types based on the structure of real data (like json, xml or any format you want), saving you from the effort of building wrapper types by hand.
No, it has parsed it into a structure that structurally has at least one element, not just the promise that there ought to be one. From the original “Parse, don’t validate” article:
data NonEmpty a = a :| [a]
> your type still permits {name: "\0\0\0\0\0\0", host: "!"}I actually originally wrote it with an array of EmailNameCharacters, etc but didn’t want to overcomplicate the example.
There are some techniques that aren't immediately obvious. Look into...
- type guards
- pushing constraints up: `function print(i: Invoice & { issueDate: string })` is better than `assert(i.issueDate)`
- discriminated unions
If you have a validator function `function isSomeSpecificType(obj: unknown): boolean` you make it a Type Guard by changing the return type to be the type assertion you need: `function isSomeSpecificType(obj: unknown): obj is SomeSpecificType`. Typescript's narrowing is pretty good about using Type Guards to good advantage.
If I parsed an emailAddress the thing that came out it would look like {'domain':'example.com', 'user':'john-doe'} or emailaddr.domain emailaddr.user and a emailaddr.address method if you like that form. Even if what I parsed ended up as a single string-like field, I would still name that field. emailaddr.address
Salutes for the bit on hiding the constructor, that makes a lot of sense.
It probably does not help anything that in my one attempt at making a javascript web application I did not bother trying to understand how javascript likes it's objects and just forced a python looking model onto it. If any of the web development team saw my code I would definitely get laughed out of the club.
You're not wrong, I assume. My problem is specifically with the remaining languages without anything like that. :')
Using types like this also means you can more easily avoid assignment errors, as everything will have a very specific type (e.g. Age instead of int).
Also, what are we doing using AI to write our blogs? Surely that's the final domain of human writing outside of our local circle?
"If I could add one feature to Typescript it would be something like "as" that actually validates the result against the type system and can fail." - I don't think it's fair to expect that since most of the statically typed languages will not guarantee things in runtime unless you specifically run a validation code in runtime.
There's also type guards and good old self-written validation functions you can use.
It's more about writing
struct User {phone: MaybePhoneNumber} // give or take, it's a monoid
over struct User {phone: Option<String>}Suppose you want to add one more property to VerifiedUserWithBirthday and UnverifiedUserWithBirthday, you might get 2 more new types, and somewhere at the higher layer call chains you need to know which enclosing type you should pass so that some method in the bottom chain will accept it.
I am sure there are more elegant ways, but I am struggling to generalize it to most enterprise SaaS CRUD apps, where you have one object with bunch of properties and can conditionally traverse the code logic
zod can't be a dev only dependency, and you have to deal with breaking changes and maybe switching to a completely different library in a few years (joi, with a syntax very similar to zod's, was very popular a while ago too).
What do you mean?
I'm into Effect from long time and it really scales well the more complex your applications.
Schema is way more advanced than Zod by the way, both at type level and functionality it has a proper decoder/encoder architecture.
You can encode "this isn't just a string -> non-empty-string -> valid email pattern" but a confirmed email the user has clicked on at the type level, by leveraging effectful schemas (and durable workflows if you want).
You may not need it 99% of the time, I myself rarely use that, but it's not a fair comparison.
Zod is more ergonomic, has easier apis and is perfect for most users. Would not recommend schema unless one buys the whole package.
If the result is better for having used AI, why wouldn't an author want to disclose it?
> Booleans look tidy until somebody adds a third case and exhaustiveness silently doesn’t kick in. Strings narrow honestly.
Like, nobody truly writes like that. It wouldn't get past any competent editor.
Strings narrow honestly? What does that even mean? This kind of 3-word precision is useless and they appear everywhere in the article. We get the point with in the first sentence, no need to add more.
It's even recommended in the official typescript docs - https://www.typescriptlang.org/docs/handbook/2/narrowing.htm...
If I have a value of type X in a static language, then I know that it absolutely conforms to the layout of type X. It isn't even that we have to provide a "validation function", it is that it is quite literally physically impossible for my value to not conform to the definition of type X, in the languages like C or Go or Rust where the type layout is actually a specification of a layout of data in memory. If I have JSON '{"a": 1}', there is no way whatsoever to stick that in a "struct { A string }", because it physically doesn't fit. By "physically", I mean, in RAM, in the physical cells and voltages. There's no way to validate that a "struct { A int }" 'really contains an int' because there is no way for it to be anything else.
Typescript specifically has these issues because all of its objects boil down to a Javascript object with certain keys, and all of the values are ultimately of type "any" no matter what Typescript tries to lay on top of it. If I have this sort of data come in to a static language, I have to have a step where it very deliberately converts it down to the static representation. There isn't an equivalent of "as". Modulo unsafe, but we don't count unsafe in these sorts of discussions.
I am absolutely guaranteed in a static language that if my struct says field "A" is an int, it absolutely, positively is, always has been, and always will be.
The main problem I encounter with "as" is when I have external data coming in. For that I have zod and validation functions. What prompted my post is my experience yesterday where I corrected an AI using "as" (which it used because a lot of its training data does this) and had it call an actual validation function that I happened to already have before it cast it into the type. But the reason my Haskell programmer screams inside is that a validation function can still be wrong, because the compiler isn't helping me. In a static language, I can guarantee that if I have "I dunno, some JSON value" on one side and a struct comes out the other end with some value derived from it, I have absolutely had some bit of code check that and pack it into the static value in a way that the compiler has helped check. I can further reliably compose these promises quite reliably through further type specification in my static type.
A validation function can still have bugs in it that a static language compiler would have strictly, compile-time validated. It's better, but it's still the manifestation of the quite accurate criticism that dynamic languages end up trading away all their convenience with not specifying types with having to have vast swathes of validation, and testing of that validation, in the testing backend. In Typescript, I can mostly sorta kinda compose them together, but it takes a lot more features and grease and effort. I appreciate Typescript in its capacity as taming Javascript and prefer it substantially over Javascript alone, it's probably the best thing of its type that we could hope for, but if I consider it as a language that stands alone, I really really dislike it.
> monoid
nullables with `??` and `?.` are also give-or-take monoids. is it common though to `or` two MaybePhoneNumbers together or to apply a PhoneNumber->MaybePhoneNumber function to it? if not then why mention it?
let's see something meaningfully different like a database schema.
[1] https://esolangs.org/wiki/Trivial_brainfuck_substitution
If you have VerifiedUserWithBirthday, any value that fails the parsing function is implicitly UnverifiedUserOrUserWithoutBirthday... No need to define it separately. You get the inverse type for free IE a value that is of type User and not of type VerifiedUserWithBirthday.
A new property doesn't mean a new derived type. Only if that new property impacts what a VerifiedUserWithBirthday should represent should the VerifiedUserWithBirthday type be updated and even then, it's not a new type, just an update to an existing type. Again minimal updates needed.
The compiler handles all the validation and will tell you exactly where there are any issues - the compiler is what makes the maintenance cost quite low.
But you hit performance and/or outright computational limits (halting problem) rather quickly.
This is a great example of the latest "LLM tell" I'm seeing in prose.
It's so terse with its "power-verb" that I have to read it multiple times. It's a clever compaction of English, not something I want to read outside of a headline or motto.
Here's another example from a Claude convo I had open: "Alerts flag mirrors". It's agreeing with my proposal that the alert system should be expanded to consider duplicates, and it came up with a cutesy phrase for it that ends up reading like three unrelated words.
Makes me appreciate how helper words help make the structure of a sentence more obvious.
More examples: "Errors surface drift", "Tests anchor scope", "Guards screen input". That's probably what it is: when the verb is also the form of a noun (flag, surface) or adjective (narrow).
Slogans mask meaning.
It’s frankly depressing when (2018) oldies-but-goodies get reposted here for the Nth time. The clarity of thought and obvious effort that went into communicating that thought was expected for top-voted posts at the time. Now those posts appear exceptional in this era’s standard of “the LLM just cleaned up my notes” slop.
What are you talking about? You'd still get the compile error just the same.
Falling back on returning the input argument doesn't even make sense in the typescript docs:
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Case circle and square are returning a number, but an unknown shape is returning itself? This is especially annoying when teammates are starting to cast values into a Shape throughout the codebase. Guess I'll need to make a PR to the typescript docs.At the end of the day, the ideas within the content are what matters. An idea has or does not have merit regardless of if it was produced entirely by a person, or by a person using AI as an editor, or 100% generated by AI. If you need a disclosure on if an idea was produced by AI, you are saying that you have no interest on debating the content on the grounds of the arguments it is making, while simultaneously ceding you can’t tell the difference between someone using AI and someone who isn’t (which undermines one of the primary arguments against AI, that it makes for inferior outputs).
Which functional language has a similarly huge ecosystem, works across the frontend/backend, has first class support of different runtimes, provides similar ergonomics, has meetups and conferences in so many countries and is easy to hire for (all you need is solid TypeScript)?
There's a reason effect-ts keeps spreading despite its syntax and learning curve, and I say it as somebody that used Haskell, functional Scala, Purescript, Elm, Racket, Elixir and tested another half a dozen.
Give me an Elixir with properly powerful types (not gleam) and I'm in.
I'd gladly throw effect and typescript especially out of my work day, but I see no sane replacement at complexity scaling.
I wouldn't personally recommend effect without a solid champion in the team and without having the complexity needs for it (I'm talking recurring durable worklows, complex encodings, suspension, retries, etc) and even if you have them the price is steep without a champion, but that's my 2 cents.
You use it for an agentic cli (opencode uses it e.g.) not a simple crud (which is 90% of web dev industry).
A translation app changes nearly 100% of the content, often changes the writer's style/voice, and can introduce hard to detect errors. But there's a far closer correspondence to what was written by the original writer. The basic ideas are still from the writer. A translation app is not expanding a short idea into something longer, and including some things the original writer never thought in the process.
***
Pre-LLMs, I did in fact disclose when I was using a translation app in some translations of scientific articles I produced. It would be weird to disclose the use of spell checking, grammar checking, or who previously taught me writing as these things are ubiquitous. I will also acknowledge people who were influential in my thinking. If a LLM is doing a lot of the thinking for me then I do think disclosing LLM use is appropriate.
In the same way, I wanna know if a book is written by some famous people just ghost written.
Of course, the point is moot. Somebody using AI to write a blog post is unlikely to be self conscious enought to thing it's necessary to disclose it in the first place.
In all fairness it does require buy-in and gradual adoption isn’t perfectly seamless or frictionless, but I think it’s worth it. They’ve done an outstanding job with it.
> power being used by the data center is renewable
That doesn't change anything about the content itself. AI writing is a disservice to the reader. Why should I even care to read an article you didn't even care about writing yourself? At this point a 300-character tweet would've achieved the same effect.
Requiring a disclaimer is essentially admitting the content isn’t meaningfully different than human generated content. At that point, who cares? Just engage with the premise on its own merits, rather than on how it was written.
The problem is the reader has to invest time to find out and LLM written text will (on average) lower the quality towards "meh" and spend more words doing so. Even if the author is making an earnest effort to produce high quality content, they need to admit to themselves and others that their results will be more hit or miss. The disclosure allows the reader to make a more informed decision about how to engage with the material (e.g., have an LLM summarize or analyze the content, or just dive in because we know it will very likely be a good read). Editing what someone has written is like reviewing code, you're by default not as invested, so the results will likely reflect that reality.
Odds are very high at this point that I've come across a piece of content I enjoyed that was at least partly written by an LLM without having detected it.
Update: 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.
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.
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.
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.
Let me show you what I mean.
Here’s the kind of code I see (and write) constantly:
interface User {
id: number;
email: string;
age: number;
}
// The actual validation is naîve and simplistic, but you get the point:
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");
}
// ...later, deeper in the call stack:
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".
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.)
We want this:
function sendWelcome(user: ValidUser) {
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.
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.
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.
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:
declare const EmailBrand: unique symbol;
declare const AgeBrand: unique symbol;
type Email = string & { readonly [EmailBrand]: true };
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.
The brand is one-way, by the way: an Email is still assignable to string. Nominal into the domain, structural on the way out, which is pretty much exactly what you want.
That function is your parser:
type ParseError = { kind: "ParseError"; message: string };
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 @" } };
}
// we've checked, now we lie to the type system on purpose
return { kind: "ok", value: raw as Email };
}
function parseAge(raw: unknown): Parsed<Age> {
if (
typeof raw !== "number" ||
!Number.isInteger(raw) ||
raw < 0 ||
raw > 150
) {
return { kind: "err", error: { kind: "ParseError", message: "bad age" } };
}
return { kind: "ok", value: raw as Age };
}
(The parseEmail predicate is embarrassingly thin — a real one would trim, lowercase, and at least pretend to validate the domain part. I’m not, however, writing an email parser in a blog post(!).) The as Email hurts a little, and it should. It’s the one place where we’re allowed to break the rules — the parser is the trusted boundary. Everywhere else in the codebase, you cannot conjure an Email out of a string. You have to call parseEmail and handle both branches. (I’m using kind: "ok" | "err" instead of a boolean discriminant on purpose. Booleans look tidy until somebody adds a third case and exhaustiveness silently doesn’t kick in. Strings narrow honestly.)
Compare this to the throw-and-pray validator we started with: its failure mode is an exception, which is invisible to the type system. The parser’s signature tells you everything that can happen. There is no third option hiding in the call stack.
Now the domain type. I want to name two things that usually get conflated: the raw blob that came off the wire, and the thing I’ve earned the right to trust.
declare const UserIdBrand: unique symbol;
type UserId = number & { readonly [UserIdBrand]: true };
type UnvalidatedUser = {
id: unknown;
email: unknown;
age: unknown;
};
type ValidUser = {
readonly id: UserId;
readonly email: Email;
readonly age: Age;
};
function parseUserId(raw: unknown): Parsed<UserId> {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
return { kind: "err", error: { kind: "ParseError", message: "bad id" } };
}
return { kind: "ok", value: raw as UserId };
}
function parseUser(raw: unknown): Parsed<ValidUser> {
if (typeof raw !== "object" || raw === null) {
return {
kind: "err",
error: { kind: "ParseError", message: "not an object" },
};
}
if (!("id" in raw) || !("email" in raw) || !("age" in raw)) {
return {
kind: "err",
error: { kind: "ParseError", message: "missing fields" },
};
}
if (typeof raw.email !== "string") {
return {
kind: "err",
error: { kind: "ParseError", message: "email not a string" },
};
}
const id = parseUserId(raw.id);
if (id.kind === "err") return id;
const email = parseEmail(raw.email);
if (email.kind === "err") return email;
const age = parseAge(raw.age);
if (age.kind === "err") return age;
return {
kind: "ok",
value: { id: id.value, email: email.value, age: age.value },
};
}
Naming UnvalidatedUser separately from ValidUser is a small DDD move that pays for itself: stuff goes in raw, stuff comes out trusted, and the boundary is a function. id is also branded — every primitive in your domain is a missed conversation, and a UserId that can’t be passed where an OrderId is expected is one of the cheapest wins in the whole technique. (No more as Record<string, unknown> either; if I’m writing a post about not lying to the type system, I shouldn’t lie to the type system.)
This is uglier than the F# or Elm equivalent, by far. I won’t pretend otherwise. The early-return-on-error pattern is the closest thing TypeScript has to a Result monad without dragging in a library, and it gets repetitive. (You can use Effect or neverthrow or fp-ts to clean this up, and for anything bigger than a toy I would. But I want to show what the language gives you out of the box, because the principle survives even when the syntax doesn’t.)
The payoff is that sendWelcome(user: ValidUser) is now genuinely safe. There is no path through your codebase that produces a ValidUser without going through parseUser. The type is the proof. The validation didn’t get thrown away.
A few things still grate.
The first is that as Email cast inside parseEmail. In a real nominal language, the smart constructor doesn’t have to lie — it returns the new type because the new type is genuinely different. In TypeScript, the brand is fictional, so you have to assert your way past it. The discipline this requires is: only the parser is allowed to do that assertion. If the cast leaks anywhere else in the codebase, the whole scheme collapses. I’ve taken to putting parsers in their own module and treating any as Brand<...> outside that module as a bug. (A custom ESLint rule helps.)
The second is exhaustiveness. Discriminated unions are TypeScript’s killer feature for this style — they’re as close as the language gets to Elm’s custom types — and the language does do exhaustiveness checking via never-narrowing; what it lacks is a dedicated match expression, so you have to write the never trick by hand and remember to write it:
function describe(result: Parsed<ValidUser>): string {
switch (result.kind) {
case "ok":
return `user ${result.value.id}`;
case "err":
return `failed: ${result.error.message}`;
default: {
const _exhaustive: never = result;
return _exhaustive;
}
}
}
Add a third variant to Parsed and the never assignment fails and the compiler tells you exactly where to look. Compare to Elm, where forgetting a branch is a compile error you literally cannot ignore.
(And while we’re here: satisfies is the other modern escape hatch worth knowing — const x = { ... } satisfies Config checks against the type without widening, so you keep the precise literal type and still get the safety. It’s the polite version of the cast.)
The third thing that grates is JSON.parse. It returns any, which is the worst type in the language and the entire reason this post exists. Annotate it as unknown immediately — const raw: unknown = JSON.parse(input) — and let the parser take it from there. JSON.parse isn’t a validator’s evil cousin; it’s a deserializer. It turns bytes into a JS value. Whether that value is a User is a completely separate question, and it’s the one your parser exists to answer.
Zod is great. So is io-ts. So is valibot. Use them. They’re the ergonomic version of everything I just wrote — a schema-first DSL that gives you a parser and a TypeScript type from the same definition:
import { z } from "zod";
const ValidUserSchema = z.object({
id: z.number().int(),
email: z.string().email().brand<"Email">(),
age: z.number().int().min(0).max(150).brand<"Age">(),
});
type ValidUser = z.infer<typeof ValidUserSchema>;
const result = ValidUserSchema.safeParse(rawInput);
safeParse returns { success: true, data } or { success: false, error } — same shape as what I built above, different field names. The .brand() call is purely type-level, exactly like the hand-rolled symbol trick; nothing happens at runtime. What you get is the parser and the type from one definition, which structurally enforces the parser/type co-location boundary I was asking you to enforce by hand a few sections ago. That alone is worth the dependency.
But — and this is the part I keep coming back to — Zod doesn’t change the mindset problem. It just makes the right thing easier. You still have to choose to use it at every boundary. You still have to resist the temptation to type-assert your way out of an error message. You still have to remember that a User from the network is not a User until something has parsed it. The library is a tool. The discipline is yours.
(I mentioned this briefly in Why TypeScript Won’t Save You, and it’s the same point: the language won’t enforce the boundary, so you have to.)
If I had to compress King’s idea into a sentence I’d actually remember at 11pm before a release: make the type system carry the proof, not your memory. Every time you check something and don’t encode the result in a type, you’re asking your future self to remember. Future you will not remember. Future you is debugging a different bug, on three hours of sleep, and is going to assume the validation already happened because of course it did, look at all these if statements. Validators leak. Parsers don’t.
In TypeScript this means leaning on three things the language does give you, even if it gives them grudgingly: branded types for nominal-ish identity, discriminated unions for honest error handling, and a strict boundary between unknown (what came from outside) and your domain types (what you’ve earned the right to trust). None of it is as clean as Elm. All of it is better than the alternative.
I still write validators sometimes. I’m not going to pretend I refactor every codebase I touch into a parsing pipeline — that would be a lie, and also probably bad use of my time. But when I find myself adding the third defensive if in three different files, all checking the same thing, I know what’s happened. I validated when I should have parsed. The information is there. It just isn’t in the type.
That’s usually when I go back and read King’s post one more time.