TypeScript Branded Types: Faking Nominal Typing with __brand to Stop Primitive Bugs

A function takes two strings. You pass them in the wrong order. TypeScript says nothing, your tests pass, and the bug reaches production, where a customer's invoice is attached to someone else's account.

This is one of the most common bugs in typed codebases, and the compiler can't catch it because of a deliberate design choice: TypeScript is structurally typed. A string is a string, whether it holds a user ID, an order ID, an email address, or a poem.

This guide explains why that happens, then shows how to fix it with branded types (also called opaque or tagged types). A brand fakes nominal typing with a phantom __brand property, so the compiler treats UserId and OrderId as different types even though both are strings at runtime. It covers the basic pattern, the unique symbol variant, validation at boundaries, numeric units, subtype brands, Zod integration, testing, and the pitfalls that catch teams out.

Key takeaways

  • TypeScript compares types by shape, not by name. Two aliases of string are fully interchangeable.
  • A brand is an intersection such as string & { readonly __brand: "UserId" }. It exists only at compile time and adds no runtime cost.