I shipped a bug last month that any half-decent type system should have caught, and TypeScript let it slide without blinking. I was refactoring a billing flow and swapped the order of two arguments in a function call. Both were strings. One was a userId, the other was an accountId. The compiler didn’t care. The refund went to the wrong place. The customer was, understandably, not delighted.
That bug is the whole reason I started reaching for branded types. If you’ve never used them, they look like a weird TypeScript trick. Once you’ve been bitten, they look like a seatbelt.
Short version for the impatient: branded types let you give a plain string or number a private “brand” that the compiler tracks, so a UserId can’t accidentally be used where an AccountId is expected, even though both are strings at runtime. No overhead. No magic. A few lines of type plumbing. I’ll show you when I actually bother and when I don’t.
What a branded type actually is
The whole idea fits in two lines:
type Brand<K, T> = K & { readonly __brand: T };
type UserId = Brand<string, "UserId">;
type AccountId = Brand<string, "AccountId">;
That’s it. UserId is “a string, but also a thing with a secret property called __brand whose type is the literal string "UserId".” The secret property doesn’t exist at runtime. You never set it. TypeScript just pretends it’s there to tell UserId apart from AccountId in the type checker.
Assigning a plain string to a UserId now fails:
const id: UserId = "u_123"; // Error: Type 'string' is not assignable to type 'UserId'.
You mint one with a tiny cast, usually hidden inside a constructor or a parser:
const parseUserId = (s: string): UserId => {
if (!s.startsWith("u_")) throw new Error(`Bad UserId: ${s}`);
return s as UserId;
};
Now any string that flows through parseUserId comes out the other side as a UserId. Anything that didn’t is just a string, and the compiler will refuse to let you pass it where a UserId is expected. The pattern is covered in depth in the Total TypeScript branded types writeup, which is where I first saw it done cleanly.
Before branded types, the billing function looked like this:
// Before: both parameters are plain strings. Swap them and nothing complains.
function issueRefund(userId: string, accountId: string, amountCents: number) {
// ...
}
issueRefund(account.id, user.id, 500); // compiles happily. ships a bug.
After:
// After: the compiler refuses to let you swap them.
function issueRefund(userId: UserId, accountId: AccountId, amountCents: Cents) {
// ...
}
issueRefund(account.id, user.id, 500);
// Error: Argument of type 'AccountId' is not assignable to parameter of type 'UserId'.
The fix is that ugly. But the shipped bug was uglier.
When I actually reach for them
I’ve used branded types in production for about a year. Here is the honest list of where they earn their keep:
- Entity IDs that are the same primitive shape.
UserId,AccountId,OrgId,OrderId— they’re all strings, they all look likeu_abc123, and they cause the worst kind of bug because the compiler agrees with your mistake. This is the textbook case. - Money.
Cents,Dollars, or brandedAED/USDamounts. Every team I’ve worked with has at least one war story about a function that expected cents and got dollars, or the other way around. Numbers look identical. Customers notice. - Validated or sanitized input.
Email,SlugifiedString,TrustedHtml,ParsedIsoDate. The brand signals “this value has been through a check.” If you want the branded form, you go through the parser. No parser, no brand. - Unit-bearing scalars. Milliseconds vs seconds. Radians vs degrees. I have never worked on a project that confused these on purpose.
Most of these overlap with what I touched on when I wrote about when to reach for TypeScript’s satisfies operator in my earlier note on satisfies. Branded types and satisfies aren’t competing features. They solve different problems. But they both live in the “tell the compiler a little more” bucket.
When I don’t bother
Branded types have a tax. Every place where a plain primitive crosses into your domain, someone has to write a cast or a parser. If the cost of the cast is higher than the cost of the bug, the trade is bad.
I skip branded types when:
- The primitive only lives inside one function. A local
userId: stringinside a 20-line handler that never leaves the handler doesn’t need a brand. You can see the whole story on one screen. - I’m prototyping. If the code is going to be rewritten next week, the branded types are rework, not safety.
- The type already has a nominal shape. A
Date, a class instance, or a discriminated union already can’t be confused with another type. Don’t re-brand things that are already distinct. - The team is new to TypeScript. Branded types look like a hex from another language. If half the team hasn’t met them yet, I’ll add them later, after a short internal writeup. Fighting the team to adopt them alongside a deadline is a losing move.
The practical rule I use: brand anything that crosses a module boundary and has at least one sibling of the same primitive shape. Everything else can stay plain.
The two ways I actually write them
There are approximately a hundred blog posts about “the right way” to do branded types. In real code I’ve only ever used two shapes.
The minimal one, which I use most of the time:
type Brand<K, T> = K & { readonly __brand: T };
type UserId = Brand<string, "UserId">;
type Cents = Brand<number, "Cents">;
const asUserId = (s: string) => s as UserId;
const asCents = (n: number) => n as Cents;
And the stricter one with a unique symbol, for cases where I really don’t want the brand to collide with anything else in the type graph:
declare const BrandSymbol: unique symbol;
type Brand<K, T> = K & { readonly [BrandSymbol]: T };
The unique-symbol version is safer in theory (two brands with the same literal string can’t accidentally overlap), but I’ve never had the literal-string version bite me in practice. The TypeScript team has been kicking around native nominal or opaque types for years. Issue #202 on the TypeScript repo is the long-running discussion, and every time I check, the answer is “not yet, use an intersection.” So I stopped waiting.
Gotchas that actually bit me
A few things I learned the annoying way:
- JSON round-trips strip the brand. Of course they do. The brand is a type-level fiction. When data comes back from the network, it’s a plain string. Parse it through a schema (I use Zod for this) and re-brand on the way in. That’s the only safe seam.
- Narrowing can surprise you.
if (typeof x === "string")narrows aUserIdtostring, because at runtime it really is a string. Inside the block, you’ve lost the brand. If you need it back, re-cast. It’s fine, just know it happens. - Overloading and tuples get weird. If you have a function with two string parameters and you brand one of them later, any existing callers that passed plain strings start failing all at once. Expect a noisy PR. Plan it.
- Don’t export the cast helpers casually. If every file has access to
asUserId, the brand becomes ceremonial. Keep the constructors close to the parser or the DB layer, and let the rest of the app receive already-branded values.
The migration path I actually use
When I retrofit branded types into an existing codebase, I don’t try to do it in one sweep. The playbook I use on client projects, which is the same kind of work I ship through my consulting practice, goes like this:
- Pick one high-blast-radius primitive. Usually it’s a user ID or a money amount, because those are the ones that cost real money when they get swapped.
- Define the branded type and the parser in one file. Export both.
- Change the DB layer and the API boundary to hand back branded values. This is the only place casts happen.
- Let the type errors guide you outward. Every
stringthat flowed from the DB to a function signature lights up. Fix the signatures, not the values. - Lock it in with a lint rule that forbids raw casts to the branded type outside the parser module.
I’ve done this on four codebases now. The first one took a week. The fourth took an afternoon. The errors you surface on the way are almost always real; I haven’t found a single branded-type retrofit where the compiler’s complaints were purely cosmetic.
One thing you can do this week
If you’ve read this far and you’re thinking “sure, fine, later,” here’s a smaller ask. Pick the one function in your codebase that takes more than two parameters of the same primitive type. Look at its callers. Count how many times the parameters are in a different order than the function signature, even if the current arguments happen to be correct. That count is your upper bound on bugs this pattern would prevent.
Brand just those parameters. Leave everything else alone. See how the PR feels. If it catches even one existing bug, the pattern has already paid for itself, and you’ll know where to reach for it next.
For me, it was the refund function. I never want to owe another customer a handwritten apology because two strings looked the same to the compiler.