You've done it. I've done it. Everyone building a SaaS has shipped code where a userId got passed where an orderId was expected — and TypeScript said nothing.
Branded types fix this. They cost zero runtime overhead and catch entire categories of bugs before your code runs.
The Problem
function getOrder(userId: string, orderId: string) {
// TypeScript doesn't care which is which
}
// This compiles fine. It is wrong.
getOrder(order.id, user.id);
Strings are strings. TypeScript's structural type system doesn't distinguish between a userId string and an orderId string — they're the same shape.
Branded Types in Under 10 Lines
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'>;
// Constructor functions (the only way to create branded values)
const UserId = (id: string): UserId => id as UserId;
const OrderId = (id: string): OrderId => id as OrderId;
const ProductId = (id: string): ProductId => id as ProductId;
Now update the function signature:
function getOrder(userId: UserId, orderId: OrderId) {
// TypeScript now enforces the distinction
}
const user = { id: UserId('usr_123') };
const order = { id: OrderId('ord_456') };
// ✅ Correct
getOrder(user.id, order.id);
// ❌ Type error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
getOrder(order.id, user.id);
The swap that would've caused a production incident is now a compile error.
Real-World Patterns
Branded numeric types
type Cents = Brand<number, 'Cents'>;
type Dollars = Brand<number, 'Dollars'>;
const Cents = (n: number): Cents => n as Cents;
const Dollars = (n: number): Dollars => n as Dollars;
function charge(amount: Cents) {
// always in cents, never accidentally dollars
}
const price = Dollars(9.99);
// ❌ Compile error — caught the unit mismatch
charge(price);
Unit confusion in payment code is catastrophic. This makes it impossible.
Validated strings
type Email = Brand<string, 'Email'>;
type Slug = Brand<string, 'Slug'>;
function validateEmail(raw: string): Email {
if (!raw.includes('@')) throw new Error('Invalid email');
return raw as Email;
}
function validateSlug(raw: string): Slug {
return raw.toLowerCase().replace(/[^a-z0-9-]/g, '-') as Slug;
}
// Now only validated values can flow through your system
function sendEmail(to: Email, subject: string) { ... }
The brand encodes that validation has occurred. You get a type-level audit trail.
Database IDs from Prisma/Drizzle
// Auto-brand IDs coming out of your ORM
type UserRecord = {
id: UserId;
email: Email;
stripeCustomerId: Brand<string, 'StripeCustomerId'>;
};
// Map your DB result
function toUserRecord(row: { id: string; email: string; stripe_id: string }): UserRecord {
return {
id: UserId(row.id),
email: validateEmail(row.email),
stripeCustomerId: row.stripe_id as Brand<string, 'StripeCustomerId'>,
};
}
Once branded at the boundary, the types flow correctly everywhere downstream.
Handling Arrays and Generic Utilities
// Utilities work fine with brands
const userIds: UserId[] = users.map(u => u.id);
const set = new Set<UserId>(userIds);
const map = new Map<UserId, User>();
// Generic functions accept branded types transparently
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstId: UserId | undefined = first(userIds); // ✅ brand preserved
Zod Integration
If you're using Zod for runtime validation, brands compose cleanly:
import { z } from 'zod';
const UserIdSchema = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;
// Parse at the API boundary — branded on success
const userId = UserIdSchema.parse(req.params.userId);
Now your API handler gets a UserId, not a raw string. One parse call, brand for life.
When to Use Branded Types
Yes:
- Entity IDs (user, order, product, session)
- Financial units (cents vs dollars, different currencies)
- Validated string types (email, slug, url)
- Opaque tokens (JWT, API keys) where you want to prevent logging
Skip it:
- Simple local variables that never cross function boundaries
- Performance-critical hot paths (the overhead is zero, but cognitive overhead counts)
- Teams not comfortable with TypeScript internals
Zero Runtime Cost
This is worth saying clearly: branded types are erased at compile time. Your JavaScript output is identical. There is no wrapper object, no class instantiation, no runtime check — unless you add one intentionally (like validateEmail).
// TypeScript
const id: UserId = UserId('usr_123');
// Compiled JavaScript
const id = 'usr_123';
Pure type safety. No runtime tax.
Ship Faster With Pre-Built Patterns
Branded types are one of dozens of TypeScript patterns baked into the AI SaaS Starter Kit ($99) — pre-wired with Stripe, Supabase, and end-to-end type safety so you don't spend a week on plumbing.
If you want the patterns as standalone skill files for Claude Code, the Ship Fast Skill Pack ($49) includes TypeScript, architecture, and AI integration skills.
Branded types are one of those techniques that feel like overkill until the first time they catch a production bug in code review. Once you start using them for IDs, you won't go back.
What are you branding first?
Top comments (0)