Back to Blog
TypeScriptBranded TypesType SafetyDomain

TypeScript Branded Types Guide

Master TypeScript branded types for type-safe identifiers and domain modeling.

B
Bootspring Team
Engineering
January 7, 2019
6 min read

Branded types add type-level tags to distinguish structurally identical types, preventing accidental misuse. Here's how to implement them.

Basic Branded Type#

1// Brand symbol for uniqueness 2declare const brand: unique symbol; 3 4// Generic brand type 5type Brand<T, B> = T & { [brand]: B }; 6 7// Create branded types 8type UserId = Brand<string, 'UserId'>; 9type OrderId = Brand<string, 'OrderId'>; 10type Email = Brand<string, 'Email'>; 11 12// These are all strings, but TypeScript treats them differently 13function getUser(id: UserId): User { 14 // ... 15} 16 17function getOrder(id: OrderId): Order { 18 // ... 19} 20 21const userId = 'user_123' as UserId; 22const orderId = 'order_456' as OrderId; 23 24getUser(userId); // OK 25getUser(orderId); // Error: OrderId is not assignable to UserId

Validated Branded Types#

1type Email = Brand<string, 'Email'>; 2 3// Validation function that returns branded type 4function validateEmail(input: string): Email { 5 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 6 7 if (!emailRegex.test(input)) { 8 throw new Error('Invalid email format'); 9 } 10 11 return input as Email; 12} 13 14// Type guard for optional validation 15function isValidEmail(input: string): input is Email { 16 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 17 return emailRegex.test(input); 18} 19 20// Usage 21function sendEmail(to: Email, subject: string): void { 22 console.log(`Sending to ${to}`); 23} 24 25const userInput = 'user@example.com'; 26 27// Must validate first 28if (isValidEmail(userInput)) { 29 sendEmail(userInput, 'Hello'); // OK, userInput is Email 30} 31 32// Or use throwing validator 33const email = validateEmail(userInput); 34sendEmail(email, 'Hello'); // OK

Numeric Branded Types#

1type PositiveNumber = Brand<number, 'Positive'>; 2type Percentage = Brand<number, 'Percentage'>; 3type Currency = Brand<number, 'Currency'>; 4 5function toPositive(n: number): PositiveNumber { 6 if (n <= 0) { 7 throw new Error('Number must be positive'); 8 } 9 return n as PositiveNumber; 10} 11 12function toPercentage(n: number): Percentage { 13 if (n < 0 || n > 100) { 14 throw new Error('Percentage must be 0-100'); 15 } 16 return n as Percentage; 17} 18 19function toCurrency(n: number): Currency { 20 // Round to 2 decimal places 21 return Math.round(n * 100) / 100 as Currency; 22} 23 24// Type-safe calculations 25function calculateDiscount( 26 price: Currency, 27 discount: Percentage 28): Currency { 29 const discountAmount = (price * discount) / 100; 30 return toCurrency(price - discountAmount); 31}

ID Types#

1// Different ID types for different entities 2type UserId = Brand<string, 'UserId'>; 3type PostId = Brand<string, 'PostId'>; 4type CommentId = Brand<string, 'CommentId'>; 5 6// Factory functions 7function createUserId(id: string): UserId { 8 return `user_${id}` as UserId; 9} 10 11function createPostId(id: string): PostId { 12 return `post_${id}` as PostId; 13} 14 15// Type-safe API 16interface Post { 17 id: PostId; 18 authorId: UserId; 19 title: string; 20} 21 22interface Comment { 23 id: CommentId; 24 postId: PostId; 25 authorId: UserId; 26 content: string; 27} 28 29function getPostComments(postId: PostId): Comment[] { 30 // ... 31} 32 33function getUserPosts(userId: UserId): Post[] { 34 // ... 35} 36 37// Can't mix up IDs 38const userId = createUserId('123'); 39const postId = createPostId('456'); 40 41getUserPosts(userId); // OK 42getUserPosts(postId); // Error!

Opaque Types#

1// More explicit opaque type pattern 2type Opaque<T, K extends string> = T & { __opaque__: K }; 3 4type Seconds = Opaque<number, 'Seconds'>; 5type Milliseconds = Opaque<number, 'Milliseconds'>; 6type Minutes = Opaque<number, 'Minutes'>; 7 8function seconds(n: number): Seconds { 9 return n as Seconds; 10} 11 12function milliseconds(n: number): Milliseconds { 13 return n as Milliseconds; 14} 15 16function minutes(n: number): Minutes { 17 return n as Minutes; 18} 19 20// Conversion functions 21function secondsToMilliseconds(s: Seconds): Milliseconds { 22 return (s * 1000) as Milliseconds; 23} 24 25function minutesToSeconds(m: Minutes): Seconds { 26 return (m * 60) as Seconds; 27} 28 29// Type-safe delay 30function delay(ms: Milliseconds): Promise<void> { 31 return new Promise(resolve => setTimeout(resolve, ms)); 32} 33 34// Usage 35await delay(milliseconds(1000)); // OK 36await delay(seconds(1)); // Error! Must convert first 37await delay(secondsToMilliseconds(seconds(1))); // OK

Path Types#

1type AbsolutePath = Brand<string, 'AbsolutePath'>; 2type RelativePath = Brand<string, 'RelativePath'>; 3type FilePath = Brand<string, 'FilePath'>; 4type DirectoryPath = Brand<string, 'DirectoryPath'>; 5 6function toAbsolutePath(path: string): AbsolutePath { 7 if (!path.startsWith('/')) { 8 throw new Error('Absolute path must start with /'); 9 } 10 return path as AbsolutePath; 11} 12 13function toRelativePath(path: string): RelativePath { 14 if (path.startsWith('/')) { 15 throw new Error('Relative path must not start with /'); 16 } 17 return path as RelativePath; 18} 19 20function joinPaths( 21 base: AbsolutePath, 22 relative: RelativePath 23): AbsolutePath { 24 return `${base}/${relative}` as AbsolutePath; 25} 26 27// Usage 28const root = toAbsolutePath('/home/user'); 29const subdir = toRelativePath('documents/file.txt'); 30const full = joinPaths(root, subdir); // /home/user/documents/file.txt

Sanitized Strings#

1type SafeHtml = Brand<string, 'SafeHtml'>; 2type SqlSafe = Brand<string, 'SqlSafe'>; 3type UrlEncoded = Brand<string, 'UrlEncoded'>; 4 5function sanitizeHtml(input: string): SafeHtml { 6 const escaped = input 7 .replace(/&/g, '&amp;') 8 .replace(/</g, '&lt;') 9 .replace(/>/g, '&gt;') 10 .replace(/"/g, '&quot;') 11 .replace(/'/g, '&#039;'); 12 return escaped as SafeHtml; 13} 14 15function escapeSql(input: string): SqlSafe { 16 const escaped = input.replace(/'/g, "''"); 17 return escaped as SqlSafe; 18} 19 20function encodeUrl(input: string): UrlEncoded { 21 return encodeURIComponent(input) as UrlEncoded; 22} 23 24// Only accept sanitized input 25function renderHtml(html: SafeHtml): void { 26 document.body.innerHTML = html; 27} 28 29function buildQuery(value: SqlSafe): string { 30 return `SELECT * FROM users WHERE name = '${value}'`; 31} 32 33// Must sanitize first 34const userInput = '<script>alert("xss")</script>'; 35renderHtml(sanitizeHtml(userInput)); // Safe

Combining Brands#

1// Multiple brands on same type 2type NonEmpty<T> = T & { __nonEmpty__: true }; 3type Trimmed<T> = T & { __trimmed__: true }; 4 5type NonEmptyString = NonEmpty<string>; 6type TrimmedString = Trimmed<string>; 7type CleanString = NonEmpty<Trimmed<string>>; 8 9function assertNonEmpty(s: string): NonEmptyString { 10 if (s.length === 0) { 11 throw new Error('String cannot be empty'); 12 } 13 return s as NonEmptyString; 14} 15 16function trim(s: string): TrimmedString { 17 return s.trim() as TrimmedString; 18} 19 20function clean(s: string): CleanString { 21 const trimmed = s.trim(); 22 if (trimmed.length === 0) { 23 throw new Error('String cannot be empty'); 24 } 25 return trimmed as CleanString; 26}

Generic Brand Utilities#

1// Utility for creating brand constructors 2function createBrand<T, B extends string>( 3 name: B, 4 validate?: (value: T) => boolean 5) { 6 type Branded = Brand<T, B>; 7 8 return { 9 create(value: T): Branded { 10 if (validate && !validate(value)) { 11 throw new Error(`Invalid ${name}`); 12 } 13 return value as Branded; 14 }, 15 16 is(value: T): value is Branded { 17 return validate ? validate(value) : true; 18 }, 19 20 unsafe(value: T): Branded { 21 return value as Branded; 22 }, 23 }; 24} 25 26// Usage 27const Email = createBrand('Email', (s: string) => 28 /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) 29); 30 31const Url = createBrand('Url', (s: string) => { 32 try { 33 new URL(s); 34 return true; 35 } catch { 36 return false; 37 } 38}); 39 40const email = Email.create('user@example.com'); 41const url = Url.create('https://example.com'); 42 43if (Email.is(userInput)) { 44 // userInput is Email type 45}

Best Practices#

Design: ✓ Use for domain primitives ✓ Create validation functions ✓ Provide type guards ✓ Document constraints Naming: ✓ Clear, descriptive names ✓ Match domain language ✓ Indicate constraints ✓ Use consistent patterns Validation: ✓ Validate at boundaries ✓ Throw descriptive errors ✓ Provide unsafe escape hatch ✓ Use type guards for optional Avoid: ✗ Over-branding everything ✗ Complex nested brands ✗ Skipping validation ✗ Runtime type checking

Conclusion#

Branded types prevent mixing up structurally identical types like different ID types or validated strings. Use them for domain primitives that have semantic meaning, create validation functions that return branded types, and combine with type guards for flexible usage. They provide compile-time safety without runtime overhead.

Share this article

Help spread the word about Bootspring