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 treats two string aliases as the same type, even when one represents a user ID and the other an order ID. A brand adds a compile-time distinction so functions can reject accidental identifier mixups.
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. The signature helps catch unvalidated input at checked call sites, though assertions and untyped data can bypass the brand.
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 in checked code, and the brand itself adds no runtime representation. Constructor validation still executes at runtime.
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