Back to Blog
TypeScriptJuly 7, 20264 min read

Implementing TypeScript Branded Types for Domain-Driven Design

Master TypeScript branded types to prevent primitive obsession. Learn how to enforce domain rules at compile-time without adding runtime overhead.

TypeScriptDomain-Driven DesignType SafetyProgramming TipsSoftware Architecture

We’ve all been there: you’re debugging a production issue, and it turns out someone passed a UserId into a function expecting an OrderId. Because both are just strings, TypeScript stays silent, the code runs, and the database record gets corrupted. This is the classic "primitive obsession" trap.

When you start applying Domain-Driven Design in TypeScript, you quickly realize that treating every ID or code as a raw string or number is a liability. Using TypeScript branded types (also known as opaque types) allows you to create nominal-like typing in a structurally typed language, ensuring that a CustomerId can never be accidentally swapped for a ProductId.

What are Branded Types?

At their core, branded types are a way to "tag" a primitive type so that TypeScript treats it as a unique, incompatible type. It’s a zero-cost abstraction; the "brand" exists only for the compiler. At runtime, your values remain simple strings or numbers, so you don't pay any performance penalty for this extra safety.

To define one, you use an intersection type with a unique property:

TYPESCRIPT
type Brand<K, T> = K & { __brand: T };

type UserId = Brand<string, CE9178">'UserId'>;
type OrderId = Brand<string, CE9178">'OrderId'>;

Even though both UserId and OrderId are strings at runtime, the compiler now views them as distinct types. If you try to pass a UserId to a function requiring an OrderId, TypeScript will throw an error. This is a massive win for preventing primitive obsession in your business logic.

Implementing Branded Types for Domain Integrity

When I first started using this approach, I tried to manually cast types everywhere. It was messy and error-prone. Instead, I found it’s better to wrap the instantiation in a factory function or a validator. If you're building a complex system, like a Next.js Full-Stack Web App Development project, keeping your domain layer clean is non-negotiable.

Here is how I typically structure my branded types for a clean domain model:

TYPESCRIPT
// Define the brand
type UserEmail = Brand<string, CE9178">'UserEmail'>;

// Factory function to enforce validation
function createUserEmail(email: string): UserEmail {
  if (!email.includes(CE9178">'@')) {
    throw new Error(CE9178">'Invalid email format');
  }
  return email as UserEmail;
}

function sendWelcomeEmail(email: UserEmail) {
  // Logic here...
}

const email = createUserEmail(CE9178">'rubel@example.com');
sendWelcomeEmail(email); // Works
// sendWelcomeEmail(CE9178">'not-an-email'); // Compile-time error!

This pattern effectively bridges the gap between raw data and domain concepts. It ensures that by the time data enters your core services, it has already been validated.

Why Use Branded Types Over Classes?

Sometimes people ask why I don't just use classes for everything. Classes are great, but they come with overhead. You have to instantiate them, and they aren't always easily serializable when passing data between server and client.

FeaturePrimitive TypesClassesBranded Types
Runtime OverheadNoneHighNone
Type SafetyLowHighHigh
SerializationEasyComplexEasy
Compile-time checkNoYesYes

As I’ve noted when discussing TypeScript Branded Types for Preventing Silent Data Loss, branded types give you the best of both worlds: the safety of a class-based domain model with the simplicity of primitives.

Caveats and Lessons Learned

I learned the hard way that branded types aren't a silver bullet. If you cast your types using as too often, you bypass the safety net entirely. My advice? Keep the "casting" logic confined to a thin layer at the boundary of your application—like your API input handlers or database mappers.

Also, be wary of deep object structures. If you have an object with a branded property, that property stays branded through your functions. However, if you perform operations like Object.keys() or destructuring, you sometimes have to be explicit about the types. For those interested in deeper type safety, TypeScript Branded Types: Enforcing Domain Integrity at Compile-Time offers a more detailed look at handling complex object graphs.

FAQ

Does this add extra code to my production bundle?

No. Branded types are erased at compile-time. Your final JavaScript bundle will only see the underlying string or number.

Can I use branded types with numbers?

Absolutely. type Price = Brand<number, 'Price'> works exactly the same way and is perfect for preventing accidental arithmetic errors between different currencies or units.

How do I handle JSON serialization?

Since they are just primitives at runtime, JSON.stringify works perfectly. Just remember that when you receive that JSON back, you need to re-validate it and cast it back into the branded type to regain type safety.

Branded types have saved me from countless "undefined is not a function" errors. They aren't a replacement for runtime validation, but they're the best way I've found to make invalid states unrepresentable in TypeScript. Next time you're defining an ID or a domain-specific string, try branding it—you'll likely never go back to raw primitives again.

Similar Posts