Branded types to stop mixing UserId and OrderId
Published November 10, 2026
type Brand<T, Name extends string> = T & { readonly __brand: Name };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
export const toUserId = (raw: string): UserId => raw as UserId;
export const toOrderId = (raw: string): OrderId => raw as OrderId;
export const chargeUser = (user: UserId, order: OrderId): string => `${user} paid for ${order}`;
export const receipt = chargeUser(toUserId("u_9f2a"), toOrderId("o_71cd"));What
UserId and OrderId are both plain strings underneath, but each carries a __brand property that only exists at the type level. toUserId/toOrderId are the only way to produce one from a raw string, and chargeUser can only be called with the right ID in the right slot.
Why it matters
TypeScript's structural typing means type UserId = string and type OrderId = string are the same type — nothing stops chargeUser(orderId, userId) from compiling with the arguments swapped, because both are just string as far as the checker is concerned. That's exactly the bug class branding exists to catch: two values with the same primitive representation but different meanings, where mixing them up compiles cleanly and fails, or silently corrupts data, at runtime. Branding adds a type-only tag that makes UserId and OrderId structurally distinct, so swapping them is a compile error instead of a production incident.
How it works
Brand<T, Name>intersects the real typeTwith an object type carrying a__brandproperty set to a unique string literalName. No value actually has a__brandfield at runtime — it only exists to make the type checker treatUserIdandOrderIdas incompatible.toUserId/toOrderIduseas UserId/as OrderIdto create a branded value from a raw string. This is the one place the brand's fiction is asserted rather than proven — which is why it belongs in one narrow, reviewed function, not scattered through the codebase.- Once branded, a
UserIdstill supports everystringoperation (template literals,.length, comparisons) because the intersection includes the realstringtype — branding restricts what a value can be assigned to, not what you can do with it.
Gotchas
- Branding is purely a compile-time fiction —
toUserId("anything")"validates" nothing at runtime. IfUserIdis supposed to mean "a real user's ID," pair the brand with actual validation (a database lookup, a format check) inside the one function that creates it. - The brand property has to be genuinely unused at runtime (never read, never serialized) —
JSON.stringify(userId)on a branded string still just serializes the string, since__brandnever actually exists on the value, only in its type.
Related
unknown+ a type guard — validates a value's shape at runtime; branding restricts a value's type at compile time. They solve different problems and often show up together.- Enums — also add a nominal-ish distinction, but branding works on primitives you don't control the definition of, like a
stringID from an external API.