Branded Types

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.

4 min read Level 3/5 #typescript#branded-types#nominal
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].

Lookup Types →