10LOC
TypeScriptintermediate

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 type T with an object type carrying a __brand property set to a unique string literal Name. No value actually has a __brand field at runtime — it only exists to make the type checker treat UserId and OrderId as incompatible.
  • toUserId/toOrderId use as UserId/as OrderId to 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 UserId still supports every string operation (template literals, .length, comparisons) because the intersection includes the real string type — 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. If UserId is 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 __brand never 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 string ID from an external API.