Branded and opaque types
Two ids, both strings, both crash production when you mix them up. The compiler can stop that.
A teammate ships a small refactor on a Friday. Two functions on the order page: cancelOrder(id) and refundUser(id). Both ids are strings. Somewhere in a controller, the wrong variable gets passed to the wrong function. The build is green. The tests are green. On Monday morning a customer's account gets cancelled instead of their order, and someone is going to spend the next three hours figuring out why.
Two ids, one type
The string type is too generous. Almost every identifier in a real app is a string — user ids, order ids, product skus, session tokens, slugs. They're all strings. That means function cancelOrder(orderId: string) will happily accept any string a caller hands it.
function cancelOrder(orderId: string) { /* ... */ }
function refundUser(userId: string) { /* ... */ }
const userId = "u_abc";
const orderId = "o_123";
cancelOrder(userId); /* compiles. ships. wrong customer. */
refundUser(orderId); /* also compiles. */Both calls type-check. The variable names suggest a relationship, but the type doesn't enforce one. Naming things orderId and userId is a hint to a human reader; the compiler sees string on both sides and waves the call through. This is the bug branding solves.
A function that takes string for an id is taking any string — including the wrong kind, including unsanitised input, including the empty string. Names in your code don't filter values; types do.
Adding a brand
A branded type is a string (or number) that carries an extra phantom property only the type system can see. The runtime value stays a plain string. The type, though, becomes distinct from every other string in your codebase.
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function cancelOrder(orderId: OrderId) { /* ... */ }
const raw: string = "o_123";
cancelOrder(raw); /* error: string is not assignable to OrderId */The & in string & { __brand: "UserId" } is intersection — the type is everything string is plus an invisible field called __brand whose value must be the literal "UserId". No plain string carries that field, so a plain string is no longer assignable to UserId. And because UserId and OrderId have different brands, you can't pass a UserId where an OrderId is expected, even though both are strings underneath.
The phantom field is never read. It exists only at compile time. There's no runtime cost — at runtime, userId is just a string, and the JavaScript engine never sees the __brand field because it was never set.
UserId is string & { __brand: "UserId" } and you pass a UserId to a function that expects a plain string, what happens?Constructor functions
You still need to get a UserId from somewhere. Plain strings come from JSON, URL parameters, form inputs. The pattern is to put the conversion in one place — a tiny constructor function that does whatever validation you trust, then asserts the brand.
type UserId = string & { readonly __brand: "UserId" };
export function userId(raw: string): UserId {
if (!/^u_[a-z0-9]+$/.test(raw)) {
throw new Error("not a UserId: " + raw);
}
return raw as UserId; /* the cast is allowed because we just checked */
}The cast raw as UserId is the one place a plain string is allowed to become a UserId. Everywhere else in the codebase, the type system forbids that cast — or at least makes it visibly suspicious. The userId function is the only door from string-land into UserId-land. If a string comes through that door, it has been validated.
The runtime check inside the function isn't required for the type to work — return raw as UserId would compile without it. It's required for honesty: the function is claiming that what comes out is a real UserId, and the only way to claim that truthfully is to look at the value. If you skip the check, you've turned a type-safety pattern into a vibes-based pattern.
import { userId } from "./ids";
const fromUrl = userId(params.get("user") ?? ""); /* throws if bad */
cancelOrder(fromUrl); /* still a compile error: UserId is not OrderId */
refundUser(fromUrl); /* compiles */A UserId made by userId() flows freely as a UserId. It still cannot accidentally substitute for an OrderId, because the compile-time brands don't match.
Put your branded types and their constructors in one module. Export the constructor; export the type. Don't export a way to make a brand without going through the constructor — that's the whole point.
Opaque, not just branded
There's a stricter cousin called opaque types. A branded type is still openly a string — anyone can call (raw as UserId) and the cast will be allowed. An opaque type hides the underlying representation so that even the cast is awkward. TypeScript doesn't have a built-in opaque keyword, but the pattern is the same — a brand plus an unused symbol — and the convention is that only the module that defines the type is allowed to construct values of it.
declare const userIdBrand: unique symbol;
export type UserId = string & { readonly [userIdBrand]: true };
export function userId(raw: string): UserId {
if (!/^u_[a-z0-9]+$/.test(raw)) throw new Error("bad UserId");
return raw as UserId;
}unique symbol makes the brand key impossible to reproduce from outside the file. A consumer can't write string & { [userIdBrand]: true } themselves because they can't reach userIdBrand. The casting trick still technically compiles (TypeScript can't fully prevent casts), but it's now visibly wrong code rather than something a teammate might write by accident.
For most apps, the lighter brand is enough. Reach for the opaque variant when the constructor is doing real work — cryptographic signing, parsing JWTs, anything where a fake value would defeat the purpose.
UserId and a plain string at the moment your code runs?const id = req.params.id as UserId;. What's the smell?OrderId. You pass it a value typed UserId. What does the compiler do?