Implements TypeScript branded types (nominal typing) to create distinct types from primitives, preventing accidental type confusion between semantically different values like UserId and PostId.
// Brand symbol for creating unique type markers
declare const __brand: unique symbol;
// Generic Brand type - creates a nominal type from a base type
type Brand<T, TBrand extends string> = T & { readonly [__brand]: TBrand };
// Branded type definitions
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
type Email = Brand<string, 'Email'>;
type PositiveNumber = Brand<number, 'PositiveNumber'>;
type NonEmptyString = Brand<string, 'NonEmptyString'>;
// Validation result type for better error handling
type ValidationResult<T> =
| { success: true; value: T }
| { success: false; error: string };
// Generic brand creator with validation
function createBrand<T, TBrand extends string>(
validate: (value: T) => boolean,
errorMessage: string
) {
return {
create(value: T): Brand<T, TBrand> {
if (!validate(value)) {
throw new Error(errorMessage);
}
return value as Brand<T, TBrand>;
},
safeParse(value: T): ValidationResult<Brand<T, TBrand>> {
if (!validate(value)) {
return { success: false, error: errorMessage };
}
return { success: true, value: value as Brand<T, TBrand> };
},
is(value: T): value is Brand<T, TBrand> {
return validate(value);
}
};
}
// Branded type constructors with validation
const UserId = createBrand<string, 'UserId'>(
(v) => typeof v === 'string' && v.length > 0 && /^user_[a-z0-9]+$/i.test(v),
'UserId must be a non-empty string matching pattern user_xxx'
);
const PostId = createBrand<string, 'PostId'>(
(v) => typeof v === 'string' && v.length > 0 && /^post_[a-z0-9]+$/i.test(v),
'PostId must be a non-empty string matching pattern post_xxx'
);
const Email = createBrand<string, 'Email'>(
(v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
'Invalid email format'
);
const PositiveNumber = createBrand<number, 'PositiveNumber'>(
(v) => typeof v === 'number' && !Number.isNaN(v) && v > 0,
'Value must be a positive number'
);
const NonEmptyString = createBrand<string, 'NonEmptyString'>(
(v) => typeof v === 'string' && v.trim().length > 0,
'String must not be empty'
);
// Example domain functions that require specific branded types
function fetchUserPosts(userId: UserId): void {
console.log(`Fetching posts for user: ${userId}`);
}
function sendEmail(to: Email, subject: NonEmptyString): void {
console.log(`Sending "${subject}" to ${to}`);
}
function calculateDiscount(price: PositiveNumber, rate: PositiveNumber): number {
return price * (rate / 100);
}
function deletePost(postId: PostId, authorId: UserId): void {
console.log(`User ${authorId} deleting post ${postId}`);
}
// Usage examples demonstrating type safety
console.log('=== Creating branded values ===');
// Valid creations
const userId = UserId.create('user_abc123');
const postId = PostId.create('post_xyz789');
const email = Email.create('dev@example.com');
const price = PositiveNumber.create(99.99);
const subject = NonEmptyString.create('Hello World');
console.log('UserId:', userId);
console.log('PostId:', postId);
console.log('Email:', email);
console.log('Price:', price);
// Using branded values in functions
console.log('\n=== Using branded values ===');
fetchUserPosts(userId);
sendEmail(email, subject);
const discount = calculateDiscount(price, PositiveNumber.create(10));
console.log('Discount:', discount);
deletePost(postId, userId);
// TYPE ERRORS - These would fail at compile time:
// fetchUserPosts(postId); // Error: PostId is not assignable to UserId
// fetchUserPosts('user_abc123'); // Error: string is not assignable to UserId
// deletePost(userId, postId); // Error: arguments in wrong order
// sendEmail('not-branded@test.com', subject); // Error: string is not Email
// Safe parsing example
console.log('\n=== Safe parsing ===');
const emailResult = Email.safeParse('invalid-email');
if (emailResult.success) {
sendEmail(emailResult.value, subject);
} else {
console.log('Validation failed:', emailResult.error);
}
const validEmailResult = Email.safeParse('valid@test.com');
if (validEmailResult.success) {
console.log('Valid email created:', validEmailResult.value);
}
// Type guard example
console.log('\n=== Type guards ===');
const unknownValue = 42;
if (PositiveNumber.is(unknownValue)) {
// unknownValue is now typed as PositiveNumber
console.log('Valid positive number:', unknownValue);
}
// Error handling example
console.log('\n=== Error handling ===');
try {
const invalidPrice = PositiveNumber.create(-10);
} catch (error) {
console.log('Caught error:', (error as Error).message);
}
try {
const invalidUserId = UserId.create('invalid_format');
} catch (error) {
console.log('Caught error:', (error as Error).message);
}Branded types (also called nominal types or opaque types) solve a fundamental limitation in TypeScript's structural type system. In TypeScript, two types are compatible if they have the same structure, which means a UserId typed as string would be interchangeable with a PostId also typed as string. This can lead to subtle bugs where you accidentally pass a user ID where a post ID is expected. Branded types add a phantom property to the type signature that makes them structurally incompatible, catching these errors at compile time.
The implementation uses TypeScript's intersection types to combine the base type (like string or number) with a readonly object containing a unique brand symbol. The declare const __brand: unique symbol creates a symbol that exists only at the type level — it has no runtime cost. Each branded type gets a unique string literal as its brand ('UserId', 'PostId', etc.), making them mutually incompatible even though they share the same underlying primitive type.
The createBrand factory function generates three utilities for each branded type: create() for throwing validation, safeParse() for result-based validation, and is() for type guards. This pattern mirrors popular validation libraries like Zod and provides flexibility in how you handle invalid data. The throwing variant is useful when you're certain the data should be valid (like data from your own database), while safeParse is better for user input where validation failures are expected.
One key decision is performing runtime validation during brand creation. While the type system prevents you from accidentally mixing branded types, it can't verify that the underlying value is actually valid at runtime (since TypeScript types are erased during compilation). By coupling the branding with validation, you get both compile-time type safety and runtime correctness guarantees.
Use branded types when you have multiple primitives representing different domain concepts that should never be interchanged — IDs, measurements, currency amounts, or validated strings like emails. Avoid overusing them for every primitive in your codebase, as the added ceremony of calling constructors can reduce code readability. They're most valuable at system boundaries (API handlers, database access) where you want to guarantee that data entering your domain layer has been properly validated and typed.