Skip to content

TypeScript Branded Types: The ID Mix-Ups I Finally Stopped Shipping

TypeScript Branded Types: The ID Mix-Ups I Finally Stopped Shipping

Confession: I once had a Stripe webhook handler where userId and orderId were both plain strings. My code passed them to a function in the wrong order for two days before anyone noticed. TypeScript was cheerful about the whole thing. My customers filing refund requests were not.

That’s the pain branded types fix. TypeScript is structural, which is usually great until the day you have two values with the same shape, like two strings that happen to be different kinds of ID, that must never be confused. If you’re building a payments feature, a multi-tenant app, or anything with a lot of IDs flying around, this is the type pattern that has saved me the most late-night pages.

Short version for the impatient: brand your IDs and your currency values. Brand anything with units. Everything else is fine as-is. If you want the how and why, read on.

What a branded type actually is

TypeScript uses structural typing. That’s the “if it walks like a duck” model: if the shape matches, the types match. Nice for flexibility, terrible for anything that happens to share a shape.

Nominal typing is the opposite. A type is a type because someone named it that. Rust, Java, and C# work this way. TypeScript doesn’t, and the core team has publicly punted on adding real nominal types more than once. So the workaround people settled on is to intersect a normal type with a private “brand” tag that only your code can produce.

Here’s the pattern in its simplest form:

type Brand<T, B> = T & { readonly __brand: B };

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

UserId is still a string at runtime. Nothing changes for JSON, logs, or database drivers. But at the type level, a UserId is no longer assignable to a parameter that expects an OrderId, and vice versa. That was the entire bug I shipped.

The before/after that made me actually adopt this

Old code, real shape from a project I’d rather not name:

function refund(userId: string, orderId: string, amount: number) {
  // ...
}

// meanwhile, elsewhere:
refund(order.orderId, session.userId, order.amount);

That compiles. That deploys. That refunds the wrong human.

Now the branded version:

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents   = Brand<number, "Cents">;

function refund(userId: UserId, orderId: OrderId, amount: Cents) {
  // ...
}

refund(order.orderId, session.userId, order.amount);
//     ~~~~~~~~~~~~~~
// Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.

Compile error. Bug caught before the CI job even finishes. The Cents brand also stops me from passing dollars where I meant cents, which I’ve also done, and which is a really fun conversation with the finance team.

The one-liner brand utility I actually ship

I’ve tried a few variants of brand utilities over the years. The one I keep coming back to is dead simple:

declare const brand: unique symbol;
export type Brand<T, B extends string> = T & { readonly [brand]: B };

The unique symbol trick means the “tag” property has a type nobody outside this file can construct. That kills a bunch of ways users can accidentally forge a brand at the type level. If you’d rather not use unique symbol, a plain string literal tag works too. I use the string variant in libraries where I want the type to be portable without a symbol import. Effect’s Brand module uses that approach and their docs are probably the best deep read on the topic.

Whichever variant you pick, put the utility in one file and import it. Do not inline it. You want a single choke point where you decide what a brand looks like.

The runtime side: minting a brand without lying

Types are checked at compile time. Values that hit runtime came from somewhere: an HTTP request body, a database row, a Stripe webhook. You need a “constructor” that takes an untrusted string and returns a UserId, or refuses to.

Here’s the shape I use for IDs:

function userId(input: string): UserId {
  if (!/^usr_[a-z0-9]{24}$/.test(input)) {
    throw new Error(`Invalid UserId: ${input}`);
  }
  return input as UserId;
}

That as UserId cast is the only place in the codebase where I’m allowed to lie to the compiler. It’s fine, because every other call site now goes through this function. If the constructor rejects garbage, every downstream UserId is a real one.

For anything more complex than a regex — say, a Stripe amount that has to be a positive integer under a cap — I pair the brand with Zod. Zod parses and validates; the brand promises the resulting value is the thing you claim it is. I covered the Zod side of that pipeline in an earlier post, and branded outputs slot into it cleanly:

import { z } from "zod";

const UserIdSchema = z
  .string()
  .regex(/^usr_[a-z0-9]{24}$/)
  .transform((s) => s as UserId);

type UserId = z.infer<typeof UserIdSchema>;

Now UserIdSchema.parse(req.params.id) gives you a validated UserId. Anything that made it out of parse is real, and the type system knows.

Where branded types actually earn their keep

I don’t brand every string. That way lies madness and also 400 lines of ceremony per feature. The rule I follow after five or so projects: brand it when confusing two values would cost real money or send data to the wrong tenant.

In practice that shrinks to about four categories.

IDs across your domain, like UserId, OrderId, TenantId, SubscriptionId. If you have multi-tenant anything, brand TenantId first. It’s the most expensive thing to get wrong.

Money amounts. Cents vs Dollars, or USD vs EUR. If your billing code has ever multiplied instead of added, this is why.

Sanitized versus raw HTML or SQL. SafeHtml is a real category, and it’s how browser features like the Trusted Types API work.

Units in general. Meters, Milliseconds, Bytes. If two things in your codebase are numbers with different units, brand them.

Everything else, like user-typed names, filenames, arbitrary text, stays a plain string. Brands cost some ceremony at the boundary, and if there’s no real bug they’d catch, the ceremony isn’t earning its keep.

Where I don’t reach for them

A few cases where I’ve tried branded types and pulled them back out.

Small solo scripts. If the whole codebase fits in your head, the brand is noise. I write plenty of throwaway TypeScript that does one thing, and I’d rather move on.

Prototype phases. Early in a feature I want to shove data around freely. I add brands when the shape stabilizes, not before. Otherwise I spend the first day fighting the type system on a design that’s going to change anyway.

Library public APIs, sometimes. If you export a UserId type, every consumer of your library has to import it and construct it. That’s a real friction cost. Effect solved this well, but for simpler libraries I sometimes leave the public surface as plain strings and brand internally.

The failure mode I’ve hit twice: over-branding. If you brand everything, you end up with a codebase where every function call has an as SomeBrand at some point, which defeats the whole point. If you find yourself casting a lot, the brand isn’t buying you safety. It’s just moved the bug behind an as.

Do this before Monday

If you’ve read this far, here’s the smallest possible action.

Open your codebase. Find the file where you handle payments, or the file where you fetch a specific user by ID, or your webhook receiver. Look at the top of the function signatures. If any of them look like (id: string, otherId: string, amount: number), you have exactly the shape that costs money to get wrong.

Add a five-line Brand utility to a types.ts file. Convert one function signature. Update the two or three constructors that feed it. See what the compiler yells at you about. Those are the call sites where you were passing the wrong thing and didn’t know. I’ve never done this exercise without finding at least one real bug.

That’s the whole trick. TypeScript won’t grow real nominal types soon, from what I can tell watching the issue tracker on GitHub. You don’t need the language feature. You need five lines and the discipline to run every ID through a real constructor. I write about this kind of type-level plumbing in the rest of my work on TypeScript backends if you want more of the same.