Most TypeScript codebases treat domain identifiers as raw primitives. A userId is a string. An orderId is a string. A transaction amount is a number. TypeScript's structural type system sees them as identical shapes, so passing an orderId where a userId is expected compiles cleanly — until your database returns "user not found" at 2 AM and you spend an hour tracing a swapped argument.
This is primitive obsession, and branded types eliminate the entire problem class at compile time with zero runtime cost.
The Compiler Won't Save You (By Default)
TypeScript uses structural typing: two types are compatible if they share the same shape. For primitives, every string is compatible with every other string. This is correct behavior for a general-purpose type system, but it leaves a gap between what your domain model means and what the type checker can actually enforce.
// ❌ All three arguments are strings — the compiler can't distinguish them
async function transferFunds(
fromAccountId: string,
toAccountId: string,
referenceId: string,
): Promise<void> {
await ledger.debit(fromAccountId, referenceId); // Accidentally used referenceId here
}
// This call compiles without complaint — the arguments are in the wrong order
transferFunds(toId, fromId, accountId);None of these bugs surface as type errors. They surface as incorrect behavior in production. The type system has the information needed to catch them — it just needs you to encode the domain semantics into the types.
Branded Types: The Core Pattern
A branded type wraps a primitive with a phantom type tag. The tag is invisible at runtime — TypeScript erases it after type-checking — but it makes structurally identical primitives incompatible to the compiler.
// The foundation — a generic brand utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Domain identifiers
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type AccountId = Brand<string, "AccountId">;
// Domain scalars with units
type Cents = Brand<number, "Cents">;
type Percentage = Brand<number, "Percentage">;
type UnixTimestamp = Brand<number, "UnixTimestamp">;The __brand property never exists on any actual object. It exists only in the type system. The moment you add it, UserId and OrderId are structurally distinct, and TypeScript will refuse to accept one where the other is expected.
// ✅ Type-safe transfer — wrong ID order is a compile-time error
async function transferFunds(
from: AccountId,
to: AccountId,
amount: Cents,
): Promise<void> {
// ...
}
declare const userId: UserId;
declare const accountId: AccountId;
declare const amount: Cents;
transferFunds(accountId, accountId, amount); // ✅ Compiles
transferFunds(userId, accountId, amount);
// Error: Argument of type 'UserId' is not assignable to parameter of type 'AccountId'.Constructing Branded Values at the Boundary
The rule is simple: branded values are created in exactly two places — HTTP/RPC input validation and database read mapping. Everywhere else in the codebase receives already-branded values and never casts.
// Smart constructors — validation and branding happen together
function parseUserId(raw: unknown): UserId | null {
if (typeof raw !== "string" || raw.length < 10 || raw.length > 36) return null;
return raw as UserId; // The only place we use `as` for this type
}
function parseCents(raw: unknown): Cents | null {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return null;
return raw as Cents;
}
// API route handler — brand at the entry point
export async function POST(req: Request) {
const body = await req.json();
const userId = parseUserId(body.userId);
const amount = parseCents(body.amount);
if (!userId || !amount) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
// From here on, all downstream functions receive typed domain values
await paymentService.processPayment(userId, amount);
}If you find yourself writing as UserId inside service or repository logic, the boundary isn't holding. That cast belongs in the constructor or the schema, not scattered through business logic.
Integration with Zod
If you're validating I/O with Zod already, brands compose cleanly via .brand(). Validation and nominal typing happen in a single schema definition.
import { z } from "zod";
const UserIdSchema = z.string().min(10).max(36).brand<"UserId">();
const OrderIdSchema = z.string().uuid().brand<"OrderId">();
const CentsSchema = z.number().int().nonnegative().brand<"Cents">();
// Types are derived from schemas — no duplication
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
type Cents = z.infer<typeof CentsSchema>;
const CreateOrderSchema = z.object({
userId: UserIdSchema,
amount: CentsSchema,
});
// Parsing produces branded types automatically
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error.flatten());
// result.data.userId is UserId, result.data.amount is Cents
await orderService.create(result.data.userId, result.data.amount);Zod's .brand() uses the same phantom type mechanism internally. This is the preferred approach when you're already in a Zod-heavy codebase — the schema becomes the single source of truth for both shape and domain identity.
Branded Numerics for Financial and Time Logic
String identifiers get the most attention, but numeric brands are where this pattern prevents the most painful bugs. Mixing raw number values that represent different units — dollars vs. cents, percentages vs. decimals, seconds vs. milliseconds — is a silent correctness failure.
// ❌ Ambiguous — is discount 0.15 or 15? The caller has to read the implementation
function applyDiscount(price: number, discount: number): number {
return Math.round(price * (1 - discount));
}
// ✅ The types document and enforce the expected units
function applyDiscount(price: Cents, discount: Percentage): Cents {
return Math.round(price * (1 - discount / 100)) as Cents;
}
const price = 2000 as Cents; // $20.00 in cents
const discount = 15 as Percentage; // 15%, not 0.15
applyDiscount(price, discount); // ✅ Returns 1700 Cents = $17.00
applyDiscount(discount, price); // ❌ Type error: arguments swappedThe branded Percentage also serves as inline documentation that the function expects 15, not 0.15. That convention is enforced by the type, not by a comment that can drift out of sync.
Applying Brands in the Repository Layer
Database drivers and ORMs return plain string and number values — you need a second branding boundary when reading from storage, just as rigorous as the HTTP boundary.
// Repository — brand on the way out, every time
async function findUserById(id: UserId): Promise<User | null> {
const row = await db
.selectFrom("users")
.where("id", "=", id) // UserId extends string, so this works
.selectAll()
.executeTakeFirst();
if (!row) return null;
// Apply brands at the mapping layer
return {
id: row.id as UserId,
email: row.email,
createdAt: row.created_at as UnixTimestamp,
};
}The cast is still in one explicit place — the row mapper — not scattered across the codebase. Every consumer of findUserById receives properly typed values without knowing where the brand came from.
When Branded Types Aren't the Right Tool
Brands work well for scalars. They're not a substitute for richer modeling.
| Scenario | Better approach | Why |
|---|---|---|
| Complex format validation (email, URL) | Zod schema with .brand() output | Validation logic doesn't fit in a type |
| Domain objects with invariants | Class with private constructor | Need methods, not just a typed primitive |
| Entity state transitions | Discriminated union | Multiple shapes, not a single scalar |
| Enumerations | const enum or union literal | Closed set of known values |
| Cross-service API contracts | Generated types from OpenAPI | Schema alignment matters more than branding |
Overusing brands creates casting proliferation. If you're writing as UserId in more than two or three locations across the entire codebase for a given type, the construction boundary isn't holding.
Key Takeaways
- Structural typing makes primitives interchangeable by default — every
stringcan replace every otherstring, regardless of domain intent - Branded types cost nothing at runtime — the phantom property is a type-level fiction; no overhead, no extra bytes
- Restrict
ascasts to two places: smart constructors at I/O boundaries and repository row mappers — nowhere else - Zod's
.brand()method makes schema-driven branding the natural default for Zod-first codebases - Numeric brands are underused —
Cents,Percentage, andUnixTimestampprevent silent unit-mismatch bugs that are painful to track down in production - Brands don't replace rich domain modeling — use discriminated unions for state, classes for objects with invariants; brands are for scalars only



