Back to Blog
TypeScriptTypesSafetyPatterns

TypeScript Branded Types for Type Safety

Use branded types to prevent type confusion. From basic branding to validation to practical patterns.

B
Bootspring Team
Engineering
December 18, 2021
6 min read

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 OrderId

Creating 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'); // ✗ Error

Opaque 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 UserId

Practical 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.

Share this article

Help spread the word about Bootspring