TypeScript Branded Types: Preventing ID Mixups and Domain Modeling Errors
How to use TypeScript branded types to prevent passing a UserId where an OrderId is expected — a practical guide to nominal typing in TypeScript.
TypeScript uses structural typing: if two types have the same shape, they are interchangeable. For most cases this is what you want. For domain identifiers, it is a liability.
A UserId and an OrderId are both strings. TypeScript cannot tell them apart. Nothing stops you from passing a UserId to a function that expects an OrderId — and that bug does not crash immediately. It creates a database query against the wrong table, or returns data for the wrong entity, or silently does nothing. The kind of bug that lives in production for weeks.
Branded types are the solution.
The Problem: Structural Typing with Identifiers
type UserId = string;
type OrderId = string;
function getOrder(id: OrderId): Promise<Order> {
return db.orders.findById(id);
}
const userId: UserId = "user-abc123";
getOrder(userId); // TypeScript accepts this — both are stringAt the type level, UserId and OrderId are identical. TypeScript has no reason to reject the call. The developer made a mistake and the type system could not catch it.
TypeScript Branded Types: The Pattern
A branded type uses an intersection with a phantom property to make two otherwise identical types structurally distinct:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;The __brand property only exists at the type level — it is never set at runtime. The intersection creates a type that is structurally distinct from string and from other branded strings, while remaining assignable where string is expected.
function getOrder(id: OrderId): Promise<Order> {
return db.orders.findById(id);
}
const userId = "user-abc123" as UserId;
const orderId = "order-xyz789" as OrderId;
getOrder(orderId); // correct
getOrder(userId); // Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'The mistake is now a compile error. The __brand type-level property distinguishes UserId from OrderId even though both are strings at runtime.
Constructor Functions for Branded Types
The as assertion creates a branded value, but scattering assertions throughout the codebase defeats the purpose. Centralize creation in constructor functions:
function makeUserId(raw: string): UserId {
if (!raw.startsWith("user-")) {
throw new Error(`Invalid UserId format: ${raw}`);
}
return raw as UserId;
}
function makeOrderId(raw: string): OrderId {
if (!raw.startsWith("order-")) {
throw new Error(`Invalid OrderId format: ${raw}`);
}
return raw as OrderId;
}Now as UserId appears exactly once, inside makeUserId. The rest of the codebase creates UserId values through the constructor, which validates the format. The brand assertion is contained and auditable.
Callers:
const userId = makeUserId(user.id); // UserId — validated and branded
const orderId = makeOrderId(order.id); // OrderId — validated and branded
getOrder(orderId); // correct
getOrder(userId); // compile errorApplying Branded Types to Database Query Results
The other common application is preventing unvalidated raw data from being treated as domain types. Mark values as validated when they come through your validation layer:
type RawInput = string;
type ValidatedEmail = Brand<string, "ValidatedEmail">;
function validateEmail(raw: RawInput): ValidatedEmail {
const email = raw.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error(`Invalid email: ${raw}`);
}
return email as ValidatedEmail;
}
function sendEmail(to: ValidatedEmail, subject: string): void {
// guaranteed: to has been through email validation
}
const raw = req.body.email; // string — unvalidated
sendEmail(raw, "Welcome"); // Error: Argument of type 'string' is not assignable to parameter of type 'ValidatedEmail'
const validated = validateEmail(raw); // ValidatedEmail — validated
sendEmail(validated, "Welcome"); // correctThe function signature of sendEmail now documents and enforces a precondition. Code review and the type system together prevent calling sendEmail with unvalidated input.
Branded Types vs Opaque Types: The Tradeoff
The brand pattern above uses as inside constructor functions. A stricter alternative uses a module with a private interface, sometimes called an opaque type. The tradeoff is that the brand pattern is simpler and works within a single module, while true opaque types require careful module boundaries.
For most applications, the brand pattern provides meaningful safety at low complexity cost. The as assertion is contained in one place per type, the type system catches mixups everywhere else, and the runtime overhead is zero.
When to Use Branded Types
Use them for:
- Entity identifiers (UserId, OrderId, ProductId, SessionId)
- Validated strings (ValidatedEmail, SafeHtml, ParsedUrl)
- Units where mixing would be a bug (Dollars vs Cents, Meters vs Feet)
- Sanitized inputs (SqlSafe, HtmlEscaped)
Skip them for:
- Simple data fields where mixing is not a real risk
- Types that are only used in one function
- Cases where the nominal distinction is not actually enforced at the type level in practice
Domain modeling with branded types is one of those investments that pays off consistently as a codebase grows. If your team is building a TypeScript application with complex domain logic, Clixo can help design a type architecture that prevents the class of bugs that audits and tests rarely catch.