TypeScript uses structural typing (duck typing): two types with identical shapes are interchangeable. This causes bugs when primitive types back domain concepts — passing a userId where an orderId is expected, both typed as string. Consider a function that sends an email but accidentally receives a database ID instead of an address because both are string. Branded types solve this by adding a phantom type marker that differentiates structurally identical types at compile time with zero runtime cost.
The Brand Pattern
The core technique uses an intersection type with a phantom brand property:
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
The __brand property never exists at runtime — it is erased during compilation. TypeScript treats UserId and OrderId as distinct types because their brand differs.
function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }
const uid = "user_123" as UserId;
const oid = "ord_456" as OrderId;
getUser(uid); // OK
getUser(oid); // Type error: 'OrderId' is not assignable to 'UserId'
Use declare or make the brand property optional to avoid runtime assignment issues:
// Better: unique symbol brand prevents accidental casting
declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: true };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { [OrderIdBrand]: true };
Type Guards and Validation Functions
Since brands don’t exist at runtime, values must be validated and branded at system boundaries — API endpoints, database results, and form submissions.
// Factory function with validation
function createUserId(value: string): UserId {
if (!/^user_[a-f0-9]{24}$/.test(value)) {
throw new Error(`Invalid UserId format: ${value}`);
}
return value as UserId;
}
// Type guard
function isUserId(value: string): value is UserId {
return /^user_[a-f0-9]{24}$/.test(value);
}
// Assertion function
function assertUserId(value: string): asserts value is UserId {
if (!isUserId(value)) {
throw new Error(`Expected a valid UserId, got: ${value}`);
}
}
Compose validators for complex types and handle deserialization:
interface User {
id: UserId;
email: Email;
name: string;
}
function parseUser(data: unknown): User {
const raw = data as Record<string, unknown>;
return {
id: createUserId(String(raw.id)),
email: createEmail(String(raw.email)),
name: String(raw.name),
};
}
Use Cases for Domain Primitives
Branded types shine in domains where primitive confusion causes real damage.
| Domain | Branded Type | Prevents |
|---|---|---|
| E-commerce | UserId, OrderId, ProductId | Cross-ID assignment errors |
| Fintech | USD, JPY, EUR | Currency arithmetic mistakes |
| Healthcare | PatientId, ProviderId, ClaimId | Data leakage between patients |
| SaaS | ApiKey, SessionToken | Credential misuse |
Currency handling is a compelling example:
type USD = Brand<number, "USD">;
type JPY = Brand<number, "JPY">;
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
function addJPY(a: JPY, b: JPY): JPY {
return (a + b) as JPY;
}
const price: USD = 29.99 as USD;
const tax: USD = 3.00 as USD;
const yenAmount: JPY = 5000 as JPY;
addUSD(price, tax); // OK
addUSD(price, yenAmount); // Type error: 'JPY' not assignable to 'USD'
Generic Branded Types and Libraries
Create generic utilities for common brand patterns:
type Entity<T extends string> = Brand<string, T>;
interface Repository<T, TId extends string> {
findById(id: Entity<TId>): Promise<T>;
save(entity: T): Promise<void>;
}
// Usage with Zod for runtime validation + branding
import { z } from "zod";
const UserIdSchema = z.string().brand("UserId");
type UserId = z.infer<typeof UserIdSchema>;
const userRepo: Repository<User, "UserId"> = {
async findById(id) {
// Type-safe database query
return db.query("SELECT * FROM users WHERE id = $1", [id]);
},
async save(user) { /* ... */ },
};
| Library | Branding Approach | Runtime Cost |
|---|---|---|
type-fest | Opaque<T, Token> | Zero |
io-ts | Branded codecs | Validation only |
zod | .brand() method | Validation only |
| Manual | Intersection type | Zero |
Performance and Migration
Branded types have zero runtime overhead — the brand property is erased during compilation. Validation functions do add runtime cost, so validate once at system boundaries and propagate branded types through the internal codebase.
// Validate at boundary, propagate internally
async function handleRequest(rawId: string) {
const userId = createUserId(rawId); // Validate once
const user = await userRepo.findById(userId);
await sendEmail(user.email, welcomeMessage);
// userId and user.email are branded — type-safe throughout
}
Migrate incrementally: start with the most critical domain primitives (IDs, currency amounts), add branded types at module boundaries, and enforce with lint rules. Begin with new code or refactor one module at a time rather than a blanket migration.
Branded types provide zero-cost compile-time safety, improve code readability by communicating domain meaning through types, and prevent an entire class of bugs. The safety benefits far outweigh the minor verbosity cost. Integrate them at your system boundaries and let the type checker guard your domain logic.
