Make `UserId` and `OrderId` Actually Different
Branded Types
By default `type UserId = string` is just a label. Branded types use a phantom property to make them mutually incompatible.
What you'll learn
- Spot the structural-typing pitfall
- Add a phantom brand
- Use a constructor helper to mint branded values
TypeScript is structurally typed — two types with the same shape are interchangeable. That’s usually great, but sometimes catastrophic.
The Problem
type UserId = string;
type OrderId = string;
function getOrder(id: OrderId) { /* ... */ }
const userId: UserId = "u1";
getOrder(userId); // no error — but this is a BUG UserId and OrderId are just aliases for string. The compiler
sees them as identical.
The Brand
Attach a phantom property — a property that exists at the type level but never at runtime — to make them structurally distinct:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function getOrder(id: OrderId) { /* ... */ }
const userId = "u1" as UserId;
getOrder(userId); // ✗ type error — UserId is not assignable to OrderId The intersection with { __brand: "UserId" } adds a property that
distinguishes the two. The brands never actually exist on the
runtime string — the cast is the only place they’re “added”.
A Constructor Helper
Casting everywhere is ugly. Centralize it:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const UserId = (s: string): UserId => s as UserId;
const OrderId = (s: string): OrderId => s as OrderId;
const u = UserId("u1"); // typed as UserId
const o = OrderId("o1"); // typed as OrderId
getOrder(u); // ✗ error
getOrder(o); // ✓ The brand is invisible at runtime — typeof u === "string". The
only place the brand “exists” is in the type system.
Real Use Cases
- ID types that mustn’t mix (
UserId,OrderId,ProductId) - Validated strings (
Email,URL,NonEmptyString) - Units (
Meters,Feet,Seconds,Milliseconds)
type Email = Brand<string, "Email">;
type URL = Brand<string, "URL">;
function isEmail(s: string): s is Email {
return /^.+@.+\..+$/.test(s);
}
function sendEmail(to: Email) { /* ... */ }
const raw = "ada@example.com";
if (isEmail(raw)) {
sendEmail(raw); // ✓ narrowed to Email
}
sendEmail(raw); // ✗ raw is still just `string` A type predicate is the perfect place to mint a branded value — the runtime check and the type-level brand land together.
Trade-offs
Brands add a small amount of ceremony. Save them for IDs and
validated strings where mixing them is a real bug. Don’t brand
every number in your codebase.
Up Next
Lookup types — pulling specific value types out of objects with
T[K].