ML
TypeScript

Branded Types for Domain Safety

UserId, OrderId, Email — all of them strings to the compiler, all of them disasters when accidentally swapped. Branded types are five lines of code that prevent the whole class.

May 10, 20267 min readTypeScriptPatterns

Every codebase has the bug where someone passed a UserId where an OrderId was expected. Both are strings. The compiler is happy. Production isn't. Branded types are how you tell the compiler that a UserId is a string but not any string.

1. The pattern

type Brand<T, B> = T & { readonly __brand: B };

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email   = Brand<string, "Email">;

At runtime, every branded type is just a string. At compile time, they are distinct. You cannot assign UserId to a parameter that expects OrderId, even though both are strings under the hood.

2. How values are made

You cannot just write const u: UserId = "abc" — the compiler will refuse because the literal lacks the brand. You need a constructor that performs the validation:

function UserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) {
    throw new Error("Invalid UserId: " + raw);
  }
  return raw as UserId;
}

The cast inside the constructor is the only place you bypass the type system. Every other reference to UserId in the codebase is type-safe.

3. Where it pays off

3.1 Function signatures stop lying

function refund(orderId: OrderId, userId: UserId): Promise<Refund>;

// caller — argument order mistake:
await refund(user.id, order.id);
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

The exact bug that would have shipped now refuses to compile.

3.2 Domain primitives gain meaning

type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;

function toCents(d: Dollars): Cents {
  return (d * 100) as Cents;
}

The compiler knows that mixing the two is a bug. The number of subtle "we billed in dollars but stored in cents" bugs this prevents is non-trivial.

3.3 Parse, don't validate

This is the deeper pattern. Validation that returns a boolean is forgettable. Parsing that returns a typed value is enforced.

function parseEmail(raw: string): Email {
  if (!/^[^@]+@[^@]+\.[^@]+$/.test(raw)) {
    throw new Error("Not an email: " + raw);
  }
  return raw as Email;
}

// Now anywhere that wants Email knows the string passed parseEmail.
function send(to: Email, body: string): void;

The type Email represents "a string that passed the email parser at some point." You cannot fake it. The "is this a valid email" check happens once at the boundary, never again.

4. The runtime cost

Zero. Branded types are erased at compile time. The runtime sees a plain string. No wrapper objects, no proxy, no memory overhead.

5. Trade-offs

  • Slightly more ceremony at the boundary. You write the parser; you use it on every input.
  • Library types are not branded. You will occasionally cast at the boundary (DB result, HTTP body) — concentrate those casts in adapters so the rest of the code stays clean.
  • Doesn't replace runtime validation. A UserId coming from an HTTP body is still any at the wire — parse it first, then trust the type after.

6. The brand collision pitfall

Two libraries each export their own type UserId = string & { __brand: "UserId" }. To TypeScript, both definitions match if the brand string is the same. Use a unique brand per project, ideally with a namespace prefix:

type UserId = Brand<string, "myapp.UserId">;

The mental model

Primitive types are shapes; branded types are meanings. A string is a shape. An Email is a meaning. Every codebase has more meanings than shapes — branded types close the gap. The first time the compiler refuses to let you pass a UserId where an OrderId was expected, you'll wonder why you ever shipped without them.

Rules of thumb

  • Brand every domain ID. The cost is five lines per type; the upside is preventing the most common bug shape there is.
  • Brand money and time (Cents, Seconds, Milliseconds).
  • Brands belong on the domain side, not the wire side. Validate at the boundary, propagate brands inward.
SharePostLinkedIn

Reader Discussion

2 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Léa Dubois· SREAsks

    any chance you'd publish these as a PDF collection? would love to print and read offline on flights. screen-fatigue is real.

    May 16, 2026·6 days later
  2. Ahmed Rahman· Full StackKind words

    concise + opinionated = my favourite kind of engineering post. so many blogs hedge every claim into mush. give me the spicy take with the receipts. more please.

    May 11, 2026·1 day later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email