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 UserIdValidated 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'); // OKNumeric 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))); // OKPath 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.txtSanitized 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, '&')
8 .replace(/</g, '<')
9 .replace(/>/g, '>')
10 .replace(/"/g, '"')
11 .replace(/'/g, ''');
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)); // SafeCombining 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.