Skip to content
Zomer Gregorio

Zomer Gregorio

Software Engineer

Resume
Language
← Blog

TypeScript: How to Implement Branded Types for Compile-Time Safety

· TypeScript · Architecture · Type Safety · Refactoring

Learn how to use branded types in TypeScript to enforce domain boundaries at compile-time and eliminate primitive obsession bugs without runtime overhead.

Stay updated

Get a short note when I publish something new. Your email or browser subscription is stored only to deliver these updates; unsubscribe anytime. No account or tracking profile is required.

A confirmation email is required before notifications begin.

Introduction to Primitive Obsession in TypeScript

TypeScript's structural type system is exceptionally flexible, but this flexibility introduces specific failure modes when dealing with domain primitives. When an application relies on standard primitives like strings or numbers to represent distinct domain concepts—such as a UserId, an OrganizationId, or an encrypted AuthToken—the compiler treats them as mutually assignable if their underlying types match. This pattern, known as primitive obsession, often leads to subtle bugs where an ID of one entity is accidentally passed to a function expecting an entirely different entity.

Branded types (also known as nominal typing or tagged types) bridge the gap between structural typing and nominal domain modeling. By leveraging intersection types and unique branding symbols, developers can enforce strict compile-time boundaries without introducing any runtime performance penalty. This article explores how to implement, maintain, and scale branded types across a production TypeScript codebase.

The Core Mechanism of Branded Types

To simulate nominal typing in a structurally typed language like TypeScript, we attach a unique, phantom property to a primitive type. This property does not exist at runtime, meaning zero memory overhead, but the TypeScript compiler uses it to distinguish between otherwise identical types.

Here is a robust implementation pattern using unique symbols for the brand:

declare const __brand: unique symbol;
 
type Brand<T, TBrand extends string> = T & {
  readonly [__brand]: TBrand;
};
 
export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;

In this example, UserId and OrderId are both fundamentally strings. However, assigning a raw string or an OrderId to a variable expecting a UserId results in a compilation error. The readonly modifier on the brand property prevents accidental mutation of the brand field if an object intersection is somehow widened.

Constructor Functions and Smart Validation

Branded types are most effective when combined with validation logic. Because you cannot simply cast raw input using as UserId everywhere without losing safety guarantees, you should encapsulate creation within smart constructor functions.

function isNonEmptyString(value: unknown): value is string {
  return typeof value === 'string' && value.trim().length > 0;
}
 
export function createUserId(rawId: string): UserId {
  if (!isNonEmptyString(rawId) || !rawId.startsWith('usr_')) {
    throw new Error('Invalid UserId format');
  }
  return rawId as UserId;
}

By routing all external data—such as API request bodies, URL parameters, and database results—through these constructor functions, you establish a hardened perimeter at the boundaries of your application. Once data crosses this boundary and is successfully branded, the rest of your business logic can safely assume the data is valid and correctly typed.

Handling Database Models and Serialization

One common challenge with branded types arises when mapping database entities or serializing objects to JSON. ORMs like Prisma, Drizzle, or TypeORM return generic primitives from queries. If your domain models expect branded types, your data access layer must explicitly map raw database rows into domain types.

interface DatabaseUserRow {
  id: string;
  email: string;
}
 
interface DomainUser {
  id: UserId;
  email: string;
}
 
function mapRowToDomain(row: DatabaseUserRow): DomainUser {
  return {
    id: createUserId(row.id),
    email: row.email,
  };
}

Conversely, when serializing objects back to JSON or transmitting them over an HTTP boundary, TypeScript will treat branded types as their underlying primitives during JSON stringification, since the phantom brand properties do not exist at runtime. However, if you pass branded objects into strict validation libraries like Zod, you may need to use .brand() or transform inputs explicitly.

Edge Cases and Limitations

While branded types significantly improve type safety, developers must remain aware of certain structural limitations:

  • Widening during operations: Standard string concatenation or template literals on branded strings will widen the result back to a standard string. If you need to manipulate branded strings, wrap those utilities to return appropriately branded outputs.
  • Object destructuring and spread: Spreading a branded object or destructuring it can sometimes strip or weaken the brand depending on the TypeScript version and configuration. Always verify type inference after complex object transformations.
  • Third-party libraries: Libraries that accept generic strings will accept branded strings without issue, but functions returning primitives will require re-branding if the domain context demands it.

Verification and Testing Strategy

Branded types operate entirely within the TypeScript compiler. Therefore, your primary verification tool is the type checker itself, supplemented by unit tests for your constructor validation logic.

To ensure your types cannot be bypassed accidentally, incorporate strict compiler flags in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

You can also use assertion testing libraries or tsd to verify that invalid assignments fail at compile time, protecting your architecture against regressions during refactoring. By adopting branded types for core domain identifiers and sensitive values, you eliminate an entire class of primitive-swapping bugs before your code ever reaches production.