DEV Community

Kuncheria Kuruvilla
Kuncheria Kuruvilla

Posted on

Branded Types in TypeScript: Beyond Primitive Type Safety

When working with TypeScript, we often find ourselves dealing with values that are technically the same type but represent completely different concepts. A user ID and a product ID are both strings, but mixing them up can lead to serious bugs. This is where branded types come to the rescue.

What Are Branded Types?

Branded types (also known as nominal types or opaque types) are a TypeScript pattern that allows you to create distinct types from existing primitives, even when they have the same underlying structure. They add semantic meaning to your types, making your code more expressive and catching errors at compile time.

// Without branded types - dangerous! function getUser(userId: string): User { /* ... */ } function getProduct(productId: string): Product { /* ... */ } const userId = "user_123"; const productId = "prod_456"; // Oops! This compiles but is logically wrong getUser(productId); // TypeScript won't catch this error 
Enter fullscreen mode Exit fullscreen mode

Creating Branded Types

The most common pattern for creating branded types uses intersection types with a unique symbol:

declare const __brand: unique symbol; type Brand<T, TBrand> = T & { [__brand]: TBrand }; // Create branded types type UserId = Brand<string, "UserId">; type ProductId = Brand<string, "ProductId">; type DateString = Brand<string, "DateString">; 
Enter fullscreen mode Exit fullscreen mode

Now let's create helper functions to safely create these branded types:

function createUserId(id: string): UserId { // Add validation logic here if needed if (!id.startsWith("user_")) { throw new Error("Invalid user ID format"); } return id as UserId; } function createProductId(id: string): ProductId { if (!id.startsWith("prod_")) { throw new Error("Invalid product ID format"); } return id as ProductId; } function createDateString(date: Date): DateString { return date.toISOString() as DateString; } 
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Date Strings

One of the most practical applications of branded types is handling date strings. Consider an application where you need to work with different date formats:

type ISODateString = Brand<string, "ISODateString">; type LocalDateString = Brand<string, "LocalDateString">; type UTCDateString = Brand<string, "UTCDateString">; // Factory functions with validation function createISODateString(date: Date): ISODateString { return date.toISOString() as ISODateString; } function createLocaleDateString(date: Date): LocalDateString { return date.toLocaleDateString() as LocalDateString; } function createUTCDateString(date: Date): UTCDateString { return date.toUTCString() as UTCDateString; } // API functions that expect specific date formats function saveEventToDatabase(eventDate: ISODateString, title: string) { // Database expects ISO format console.log(`Saving event: ${title} at ${eventDate}`); } function displayEventToUser(eventDate: LocalDateString, title: string) { // UI expects localized format console.log(`Event: ${title} on ${eventDate}`); } // Usage const now = new Date(); const isoDate = createISODateString(now); const localeDate = createLocaleDateString(now); saveEventToDatabase(isoDate, "Team Meeting"); // ✅ Correct // saveEventToDatabase(localeDate, "Team Meeting"); // ❌ TypeScript error! 
Enter fullscreen mode Exit fullscreen mode

Advanced Branded Types with Validation

You can enhance branded types with runtime validation to ensure data integrity:

type Email = Brand<string, "Email">; type PhoneNumber = Brand<string, "PhoneNumber">; type PositiveNumber = Brand<number, "PositiveNumber">; function createEmail(value: string): Email { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { throw new Error(`Invalid email format: ${value}`); } return value as Email; } function createPhoneNumber(value: string): PhoneNumber { const phoneRegex = /^\+?[\d\s-()]+$/; if (!phoneRegex.test(value) || value.length < 10) { throw new Error(`Invalid phone number format: ${value}`); } return value as PhoneNumber; } function createPositiveNumber(value: number): PositiveNumber { if (value <= 0) { throw new Error(`Number must be positive: ${value}`); } return value as PositiveNumber; } // Type-safe user registration interface UserRegistration { email: Email; phone: PhoneNumber; age: PositiveNumber; } function registerUser(registration: UserRegistration) { // All inputs are guaranteed to be valid console.log("Registering user:", registration); } // Usage with validation try { const user: UserRegistration = { email: createEmail("user@example.com"), phone: createPhoneNumber("+1-555-0123"), age: createPositiveNumber(25) }; registerUser(user); } catch (error) { console.error("Registration failed:", error.message); } 
Enter fullscreen mode Exit fullscreen mode

Branded Types for API Responses

Branded types are particularly useful when working with external APIs where you want to ensure data flows correctly:

type ApiResponse<T> = Brand<T, "ApiResponse">; type ValidatedData<T> = Brand<T, "ValidatedData">; interface RawUser { id: string; email: string; created_at: string; } interface User { id: UserId; email: Email; createdAt: DateString; } function fetchUserFromApi(id: UserId): ApiResponse<RawUser> { // Simulate API call const rawUser = { id: id, email: "user@example.com", created_at: "2024-01-15T10:30:00Z" }; return rawUser as ApiResponse<RawUser>; } function validateApiUser(apiUser: ApiResponse<RawUser>): ValidatedData<User> { const validated: User = { id: createUserId(apiUser.id), email: createEmail(apiUser.email), createdAt: apiUser.created_at as DateString }; return validated as ValidatedData<User>; } function processValidatedUser(user: ValidatedData<User>) { // We know this data is safe to use console.log("Processing validated user:", user); } // Type-safe data flow const userId = createUserId("user_123"); const apiResponse = fetchUserFromApi(userId); const validatedUser = validateApiUser(apiResponse); processValidatedUser(validatedUser); 
Enter fullscreen mode Exit fullscreen mode

Utility Types for Branded Types

Here are some useful utility types to work with branded types:

// Extract the underlying type from a branded type type Unbrand<T> = T extends Brand<infer U, any> ? U : T; // Check if a type is branded type IsBranded<T> = T extends Brand<any, any> ? true : false; // Create a branded type from an existing branded type type Rebrand<T, TBrand> = Unbrand<T> extends infer U ? Brand<U, TBrand> : never; // Example usage type UnbrandedUserId = Unbrand<UserId>; // string type IsUserIdBranded = IsBranded<UserId>; // true type AdminId = Rebrand<UserId, "AdminId">; // Brand<string, "AdminId"> 
Enter fullscreen mode Exit fullscreen mode

When to Use Branded Types

Branded types are most beneficial when:

  1. Domain Separation: You have multiple concepts using the same primitive type (IDs, different string formats)
  2. API Boundaries: Ensuring data validation at system boundaries
  3. Critical Operations: Preventing mix-ups in financial calculations, medical data, etc.
  4. Configuration Management: Distinguishing between different environment configs
  5. Data Transformation Pipelines: Tracking data through validation and transformation stages

Performance Considerations

Branded types are a compile-time feature and have zero runtime overhead. The branding information is erased during compilation, so your JavaScript output remains clean and efficient.

Conclusion

Branded types are a powerful pattern for creating more expressive and safer TypeScript code. They help catch logical errors at compile time, make your intentions clearer, and provide a foundation for building robust type-safe applications.

By using branded types for concepts like date strings, IDs, and validated data, you can significantly reduce runtime errors and make your codebase more maintainable. The small upfront investment in setting up branded types pays dividends in code quality and developer confidence.

Start small by identifying a few key areas in your codebase where primitive types are being mixed up, and gradually expand your use of branded types as you see their benefits in action.


Have you used branded types in your TypeScript projects? Share your experiences and use cases in the comments below!

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.