Introduction
We've all shipped that bug where a UserId gets passed to a function expecting an OrderId, and the code compiles just fine because they're both strings. Branded IDs: concepts, trade-offs and production lessons teaches us how to make these mistakes a compile error instead. In this article, we'll explore how branded types work in TypeScript, when they're worth the investment, and the hard lessons we've learned running them in production across trading platforms, e-commerce systems, and SaaS applications.
The core problem: type safety for domain identifiers
Most applications deal with multiple kinds of identifiers. Users have IDs, orders have IDs, products have IDs, transactions have IDs. In JavaScript and early TypeScript, they're all just strings or numbers:
function getOrder(orderId: string): Order { }
function cancelSubscription(userId: string): void { }
// This compiles but is wrong
const userId = "user_12345";
getOrder(userId); // silent bug
The compiler doesn't catch this because string is string. We've accidentally passed user context to an order retrieval function. In a payment system, this could mean charging the wrong account. In a ride-hailing platform, assigning a trip to the wrong driver. These aren't theoretical—they're expensive bugs we've debugged at 2 AM.
Branded types solve this by creating a compile-time distinction between different kinds of strings, without adding runtime overhead.
What branded types are and how they work
A branded type uses TypeScript's type system to attach an invisible marker to a base type:
type UserId = string & { __brand: 'UserId' };
type OrderId = string & { __brand: 'OrderId' };
declare function getOrder(id: OrderId): Order;
declare function getUser(id: UserId): User;
const userId: UserId = "user_123" as UserId;
const orderId: OrderId = "order_456" as OrderId;
getOrder(orderId); // ✓ compiles
getUser(userId); // ✓ compiles
getOrder(userId); // ✗ compile error: OrderId is not assignable to string & { __brand: 'OrderId' }
The __brand property is never created at runtime—TypeScript erases it during compilation. What we're doing is purely a type-level trick that gives us type safety without a single byte of runtime cost. The compiled JavaScript sees only strings.
We can make this pattern more ergonomic by creating a utility type and helpers:
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type TransactionId = Brand<string, 'TransactionId'>;
// Helper to create branded types safely
function brandUserId(value: string): UserId {
return value as UserId;
}
function brandOrderId(value: string): OrderId {
return value as OrderId;
}
UserId and OrderId can't be swapped
This is the whole point. Once you've branded an ID, the compiler enforces that it's used with the correct domain entity:
type UserId = string & { __brand: 'UserId' };
type OrderId = string & { __brand: 'OrderId' };
class OrderService {
getOrder(id: OrderId): Promise<Order> { }
listUserOrders(userId: UserId): Promise<Order[]> { }
}
class UserService {
getUser(id: UserId): Promise<User> { }
updateUser(id: UserId, data: Partial<User>): Promise<void> { }
}
// In application code
const userId: UserId = "user_789" as UserId;
const orderId: OrderId = "order_101" as OrderId;
const orderService = new OrderService();
orderService.getOrder(orderId); // ✓ correct
orderService.listUserOrders(userId); // ✓ correct
orderService.getOrder(userId); // ✗ compile error
orderService.listUserOrders(orderId); // ✗ compile error
We've tested this across e-commerce platforms where mixing up customer IDs and order IDs could trigger wrong shipments, and it catches the mistake every time at the keyboard. Your IDE shows the error before you even run the code.
Zero runtime cost
The beauty of branded types is that they're a TypeScript-only construct. When your code compiles, the brand annotations disappear:
// TypeScript source
type UserId = string & { __brand: 'UserId' };
function processUser(id: UserId): void {
console.log(`Processing user: ${id}`);
}
const userId: UserId = "user_42" as UserId;
processUser(userId);
Compiles to:
// JavaScript output
function processUser(id) {
console.log(`Processing user: ${id}`);
}
const userId = "user_42";
processUser(userId);
There's no runtime wrapper, no object allocation, no performance penalty. It's pure type-checking syntax. In systems where we've benchmarked CPU-critical paths (trading engines, real-time analytics), branded types have zero measurable impact compared to plain strings.
Create via parsing or validation
You never want to let random strings become branded IDs. You should only create branded IDs through controlled functions that validate or parse the input:
type UserId = string & { __brand: 'UserId' };
// Bad: allows any string
function badCreateUserId(value: string): UserId {
return value as UserId;
}
// Better: validates format
function parseUserId(value: string): UserId | null {
if (/^user_[a-f0-9]{24}$/.test(value)) {
return value as UserId;
}
return null;
}
// Even better: throws with error context
function parseUserIdStrict(value: string): UserId {
if (/^user_[a-f0-9]{24}$/.test(value)) {
return value as UserId;
}
throw new Error(`Invalid UserId format: ${value}`);
}
// Best for untrusted input (API boundaries)
function parseUserIdSafe(value: unknown): UserId {
if (typeof value !== 'string') {
throw new Error('UserId must be a string');
}
if (!/^user_[a-f0-9]{24}$/.test(value)) {
throw new Error(`Invalid UserId format: ${value}`);
}
return value as UserId;
}
In production, we've learned to be strict at API boundaries (accepting user input) and somewhat more lenient internally (when data has already been validated). For a SaaS application, we parse all incoming IDs at the controller level, and downstream functions trust that the ID has already been validated.
Great for public function signatures
Branded IDs shine in public interfaces—functions your team calls frequently, or APIs that multiple services consume. They document the contract:
type UserId = string & { __brand: 'UserId' };
type OrderId = string & { __brand: 'OrderId' };
type TransactionId = string & { __brand: 'TransactionId' };
// Clear contract: takes specific ID types
class PaymentService {
chargeUser(userId: UserId, amount: number): Promise<TransactionId>;
refundTransaction(txnId: TransactionId): Promise<void>;
getUserTransactions(userId: UserId): Promise<TransactionId[]>;
}
// Clear contract: returns specific ID type
class OrderService {
createOrder(userId: UserId, items: OrderItem[]): Promise<OrderId>;
getOrder(orderId: OrderId): Promise<Order>;
}
Anyone reading the function signature knows exactly what kind of ID is expected. If they pass the wrong type, they get a compile error. This is self-documenting code—no JSDoc comment needed. We've used this extensively in API route handlers where the type system enforces correctness before logic runs.
Common mistakes
Mistake 1: Over-branding
Not every string needs to be branded. If a value is only used in one place and never confused with other types, a plain string is fine. Branded types add cognitive overhead. Use them strategically.
Mistake 2: Loose creation functions
We've seen codebases where any function can create a branded ID with a simple as cast. This defeats the purpose. Branded IDs should only come from controlled entry points. Create a BrandedIdFactory or similar in your domain layer and use it consistently.
Mistake 3: Not exporting brand types
If you define a branded type in one file but don't export it, other files will re-define it locally, and TypeScript won't recognize them as the same type (different nominal identity). Always export branded types from a centralized location.
// ✗ wrong: each file defines its own type
// file1.ts
type UserId = string & { __brand: 'UserId' };
// file2.ts
type UserId = string & { __brand: 'UserId' };
// These are different types to TypeScript, even though they look identical
// ✓ right: define once, export, import everywhere
// types/domain.ts
export type UserId = string & { __brand: 'UserId' };
// services/userService.ts
import { UserId } from '../types/domain';
Mistake 4: Forgetting about JSON serialization
When you serialize a branded ID to JSON and send it across the network (or to storage), it arrives as a plain string. You must re-brand it or re-validate it on the receiving side:
type UserId = string & { __brand: 'UserId' };
// Sending
const userId: UserId = "user_123" as UserId;
const json = JSON.stringify({ userId }); // Serializes to plain string
// Receiving
const received = JSON.parse(json);
const restoredUserId: UserId = parseUserIdStrict(received.userId); // Re-validate
Key takeaways
- Branded types create compile-time type safety for domain identifiers. UserId and OrderId are distinct types even though they're both strings under the hood.
- They have zero runtime cost. TypeScript erases the brand annotations during compilation, leaving only plain strings in the output.
- Create branded IDs only through controlled validation functions. Never let untrusted input bypass parsing.
- Use branded types in public function signatures. They document intent and prevent accidental misuse by callers.
- Define branded types in one place and export them. Avoid re-defining the same type in multiple files, which breaks type identity.
- Re-validate at API boundaries. When data comes from the network or storage, re-parse and re-brand it.
- Don't over-brand. Use this pattern for high-risk, frequently-confused identifiers. Not every string needs branding.






Comments
Loading comments…