Branded types prevent mixing up values with the same underlying type. Here's how to use them effectively.
The Problem#
1// Without branding - easy to mix up
2type UserId = string;
3type OrderId = string;
4type ProductId = string;
5
6function getOrder(orderId: OrderId): Order {
7 return db.orders.find(orderId);
8}
9
10const userId: UserId = 'user_123';
11const orderId: OrderId = 'order_456';
12
13// Oops! No error, but wrong ID type
14getOrder(userId); // TypeScript allows this!Basic Branded Types#
1// Brand with unique symbol
2declare const brand: unique symbol;
3
4type Brand<T, B> = T & { [brand]: B };
5
6// Create branded types
7type UserId = Brand<string, 'UserId'>;
8type OrderId = Brand<string, 'OrderId'>;
9type ProductId = Brand<string, 'ProductId'>;
10
11// Type-safe functions
12function getOrder(orderId: OrderId): Order {
13 return db.orders.find(orderId);
14}
15
16function getUser(userId: UserId): User {
17 return db.users.find(userId);
18}
19
20// Now TypeScript catches errors
21const userId = 'user_123' as UserId;
22const orderId = 'order_456' as OrderId;
23
24getOrder(orderId); // ✓ OK
25getOrder(userId); // ✗ Error: UserId not assignable to OrderIdCreating Branded Values#
1// Factory functions for branded types
2function createUserId(id: string): UserId {
3 // Add validation
4 if (!id.startsWith('user_')) {
5 throw new Error('Invalid user ID format');
6 }
7 return id as UserId;
8}
9
10function createOrderId(id: string): OrderId {
11 if (!id.startsWith('order_')) {
12 throw new Error('Invalid order ID format');
13 }
14 return id as OrderId;
15}
16
17// Usage
18const userId = createUserId('user_123'); // ✓ Valid
19const orderId = createOrderId('order_456'); // ✓ Valid
20const invalid = createUserId('invalid'); // ✗ Throws error
21
22// Generic brand creator
23function brand<T, B extends string>(
24 value: T,
25 validator?: (value: T) => boolean
26): Brand<T, B> {
27 if (validator && !validator(value)) {
28 throw new Error(`Invalid ${typeof value} for brand`);
29 }
30 return value as Brand<T, B>;
31}Branded Primitives#
1// Branded numbers
2type PositiveNumber = Brand<number, 'Positive'>;
3type Percentage = Brand<number, 'Percentage'>;
4type USD = Brand<number, 'USD'>;
5type EUR = Brand<number, 'EUR'>;
6
7function createPositive(n: number): PositiveNumber {
8 if (n <= 0) throw new Error('Must be positive');
9 return n as PositiveNumber;
10}
11
12function createPercentage(n: number): Percentage {
13 if (n < 0 || n > 100) throw new Error('Must be 0-100');
14 return n as Percentage;
15}
16
17function createUSD(amount: number): USD {
18 return Math.round(amount * 100) / 100 as USD;
19}
20
21// Prevent currency mixing
22function addUSD(a: USD, b: USD): USD {
23 return (a + b) as USD;
24}
25
26const usd = createUSD(100);
27const eur = 50 as EUR;
28
29addUSD(usd, usd); // ✓ OK
30addUSD(usd, eur); // ✗ Error: EUR not assignable to USD
31
32// Branded strings
33type Email = Brand<string, 'Email'>;
34type URL = Brand<string, 'URL'>;
35type UUID = Brand<string, 'UUID'>;
36
37function createEmail(value: string): Email {
38 if (!value.includes('@')) throw new Error('Invalid email');
39 return value as Email;
40}
41
42function createUUID(value: string): UUID {
43 const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
44 if (!uuidRegex.test(value)) throw new Error('Invalid UUID');
45 return value as UUID;
46}With Zod Validation#
1import { z } from 'zod';
2
3// Define branded schema
4const UserIdSchema = z.string()
5 .refine((s) => s.startsWith('user_'), 'Must start with user_')
6 .brand<'UserId'>();
7
8const EmailSchema = z.string()
9 .email()
10 .brand<'Email'>();
11
12const PositiveSchema = z.number()
13 .positive()
14 .brand<'Positive'>();
15
16// Infer branded types
17type UserId = z.infer<typeof UserIdSchema>;
18type Email = z.infer<typeof EmailSchema>;
19type Positive = z.infer<typeof PositiveSchema>;
20
21// Parse and brand in one step
22const userId = UserIdSchema.parse('user_123');
23const email = EmailSchema.parse('test@example.com');
24const positive = PositiveSchema.parse(42);
25
26// Type-safe function
27function sendEmail(to: Email, subject: string) {
28 // to is guaranteed to be valid email
29}
30
31sendEmail(email, 'Hello'); // ✓ OK
32sendEmail('not-branded@test.com', 'Hi'); // ✗ ErrorOpaque Types Pattern#
1// Alternative: Opaque types with intersection
2type Opaque<T, K extends string> = T & { __opaque__: K };
3
4type UserId = Opaque<string, 'UserId'>;
5type SessionId = Opaque<string, 'SessionId'>;
6
7// Module pattern for encapsulation
8// userId.ts
9declare const userIdSymbol: unique symbol;
10export type UserId = string & { [userIdSymbol]: never };
11
12export function isUserId(value: string): value is UserId {
13 return value.startsWith('user_');
14}
15
16export function toUserId(value: string): UserId {
17 if (!isUserId(value)) {
18 throw new Error('Invalid UserId');
19 }
20 return value;
21}
22
23export function unsafeToUserId(value: string): UserId {
24 return value as UserId;
25}Branded Types with Classes#
1// Class-based approach
2class UserId {
3 private readonly __brand = 'UserId';
4
5 private constructor(public readonly value: string) {}
6
7 static create(value: string): UserId {
8 if (!value.startsWith('user_')) {
9 throw new Error('Invalid UserId');
10 }
11 return new UserId(value);
12 }
13
14 static unsafe(value: string): UserId {
15 return new UserId(value);
16 }
17
18 toString(): string {
19 return this.value;
20 }
21
22 equals(other: UserId): boolean {
23 return this.value === other.value;
24 }
25}
26
27// Usage
28const id1 = UserId.create('user_123');
29const id2 = UserId.create('user_456');
30
31id1.equals(id2); // false
32
33function getUser(id: UserId): User {
34 return db.users.find(id.value);
35}
36
37getUser(id1); // ✓ OK
38getUser('user_123'); // ✗ Error: string not assignable to UserIdPractical Patterns#
1// API response types
2type ApiResponse<T> = {
3 data: T;
4 requestId: Brand<string, 'RequestId'>;
5 timestamp: Brand<number, 'UnixTimestamp'>;
6};
7
8// Database entities
9interface User {
10 id: UserId;
11 email: Email;
12 createdAt: Brand<Date, 'UTCDate'>;
13}
14
15interface Order {
16 id: OrderId;
17 userId: UserId;
18 productIds: ProductId[];
19 total: USD;
20}
21
22// Form validation
23type ValidatedForm<T> = Brand<T, 'Validated'>;
24
25function validateLoginForm(data: unknown): ValidatedForm<LoginData> {
26 const result = LoginSchema.parse(data);
27 return result as ValidatedForm<LoginData>;
28}
29
30function submitLogin(form: ValidatedForm<LoginData>) {
31 // Guaranteed to be validated
32}
33
34// Sanitized strings
35type SanitizedHTML = Brand<string, 'SanitizedHTML'>;
36
37function sanitize(html: string): SanitizedHTML {
38 const clean = DOMPurify.sanitize(html);
39 return clean as SanitizedHTML;
40}
41
42function renderHTML(html: SanitizedHTML) {
43 element.innerHTML = html; // Safe to use
44}Type Guards#
1// Type guard for branded types
2function isUserId(value: unknown): value is UserId {
3 return typeof value === 'string' && value.startsWith('user_');
4}
5
6function isEmail(value: unknown): value is Email {
7 return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
8}
9
10// Usage
11function processId(id: string) {
12 if (isUserId(id)) {
13 getUser(id); // id is UserId here
14 }
15}
16
17// Assertion function
18function assertUserId(value: string): asserts value is UserId {
19 if (!value.startsWith('user_')) {
20 throw new Error('Not a valid UserId');
21 }
22}
23
24function handleUserId(value: string) {
25 assertUserId(value);
26 // value is now UserId
27 getUser(value);
28}Best Practices#
Design:
✓ Brand semantically different values
✓ Use factory functions with validation
✓ Export type and factory together
✓ Consider runtime validation
Naming:
✓ Use descriptive brand names
✓ Match domain terminology
✓ Be consistent across codebase
✓ Document branded types
Integration:
✓ Combine with Zod/validation
✓ Use with API boundaries
✓ Brand database IDs
✓ Brand user input after validation
Conclusion#
Branded types prevent mixing up structurally identical but semantically different values. Use them for IDs, currencies, validated data, and domain concepts. Combine with validation libraries like Zod for runtime safety. The small overhead pays off in bug prevention and self-documenting code.