Introduction
Now that you understand the mechanics of branded types and smart constructors, this lesson covers the patterns used in production codebases: domain identifiers, monetary amounts, integration with schema validation libraries like Zod, and composing multiple brands.
Key Concepts
- Domain Identifier: A branded string or number representing a database primary key or external API ID.
- Monetary Type: A branded number that encodes both the amount and currency, preventing arithmetic between incompatible currencies.
- Schema-Driven Branding: Using Zod's
.brand()method to generate branded types directly from validation schemas.
Real World Context
At companies like Stripe and Shopify, mixing up customer IDs, payment IDs, and order IDs is a real source of production bugs. Branded types in TypeScript eliminate this risk at zero runtime cost. Zod's .brand() bridges the gap between runtime validation and compile-time branding automatically.
Deep Dive
Domain Identifiers
Define one branded type per database entity:
typescripttype Brand<B, T extends string> = B & { readonly __brand: T }; type UserId = Brand<string, "UserId">; type ProductId = Brand<string, "ProductId">; type OrderId = Brand<string, "OrderId">; interface Order { id: OrderId; userId: UserId; productIds: ProductId[]; }
Every function that accepts an OrderId is now impossible to call with a UserId, even though both are strings at runtime.
Monetary Amounts
Encode currency into the type to prevent cross-currency arithmetic:
typescripttype USD = Brand<number, "USD">; type EUR = Brand<number, "EUR">; function addUsd(a: USD, b: USD): USD { return (a + b) as USD; } const price = 20 as USD; const tax = 3 as USD; const total = addUsd(price, tax); // OK: USD // addUsd(price, 10 as EUR); // Error: EUR is not assignable to USD
Zod Integration
Zod's .brand() method generates a branded type from a schema, combining runtime validation and compile-time branding:
typescriptimport { z } from "zod"; const EmailSchema = z.string().email().brand<"Email">(); type Email = z.infer<typeof EmailSchema>; const result = EmailSchema.safeParse("alice@example.com"); if (result.success) { const email: Email = result.data; // Already branded }
This eliminates the need for manual smart constructors when you already use Zod for validation.
Composing Brands
You can combine multiple brands using intersection:
typescripttype NonEmpty = Brand<string, "NonEmpty">; type Trimmed = Brand<string, "Trimmed">; type CleanString = NonEmpty & Trimmed; function cleanString(input: string): CleanString | null { const trimmed = input.trim(); if (trimmed.length === 0) return null; return trimmed as unknown as CleanString; }
Common Pitfalls
- Over-branding — Not every string needs a brand. Use brands for values that cross module boundaries or represent domain identifiers. Local variables rarely benefit.
- Double-casting with composed brands — When combining brands, you may need
as unknown as ComposedTypebecause TypeScript cannot directly narrow through multiple phantom properties.
Best Practices
- Use Zod's
.brand()at API boundaries — Parse and brand incoming data in one step, then pass branded types throughout your application. - Create a brands module — Centralize all brand definitions and constructors in a single module for discoverability and consistency.
Summary
- Domain identifiers (UserId, OrderId) prevent entity-ID mixups at compile time.
- Monetary branded types prevent cross-currency arithmetic errors.
- Zod's
.brand()method unifies runtime validation with compile-time branding. - Composed brands allow stacking multiple constraints on a single value.
Code Examples
import { z } from "zod";
// Zod schema with branding — validates AND brands in one step
const UserIdSchema = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;
// At the API boundary
function handleRequest(rawId: string) {
const result = UserIdSchema.safeParse(rawId);
if (!result.success) {
throw new Error("Invalid user ID");
}
// result.data is already typed as UserId
return fetchUser(result.data);
}
function fetchUser(id: UserId): void { /* ... */ }