TypeScript Branded Types: Nominal Typing Pattern

TypeScript uses structural typing, meaning types are compatible based on their structure rather than their names. While this enables flexibility, it creates a serious problem when modeling distinct...

Key Insights

  • TypeScript’s structural typing allows incompatible values to pass type checks when they share the same shape, leading to subtle bugs when different domain concepts use the same primitive types
  • Branded types use intersection types with unique symbols to create nominal types that prevent accidental substitution, even when the underlying runtime values are identical
  • Combining branded types with validation functions creates type-safe constructors that enforce both compile-time type safety and runtime validation at domain boundaries

The Problem with Structural Typing

TypeScript uses structural typing, meaning types are compatible based on their structure rather than their names. While this enables flexibility, it creates a serious problem when modeling distinct domain concepts that happen to share the same primitive type.

Consider this common scenario:

type UserId = string;
type ProductId = string;
type OrderId = string;

function getUser(userId: UserId): User {
  return database.users.find(userId);
}

function getProduct(productId: ProductId): Product {
  return database.products.find(productId);
}

const userId: UserId = "user_123";
const productId: ProductId = "prod_456";

// This compiles fine but is semantically wrong
const user = getUser(productId); // Bug: passing ProductId where UserId expected

TypeScript accepts this code because UserId and ProductId are both just string under the hood. The type aliases provide documentation but zero type safety. This is a textbook case of primitive obsession leading to runtime bugs that should have been caught at compile time.

What Are Branded Types?

Branded types simulate nominal typing in TypeScript by adding a unique, phantom property to a type. This property exists only at compile time and has no runtime representation, making it a zero-cost abstraction.

The pattern uses intersection types with a unique brand property:

type UserId = string & { __brand: 'UserId' };
type ProductId = string & { __brand: 'ProductId' };

const userId = "user_123" as UserId;
const productId = "prod_456" as ProductId;

function getUser(userId: UserId): User {
  return database.users.find(userId);
}

// TypeScript error: ProductId is not assignable to UserId
getUser(productId); // Compile-time error!

Now TypeScript treats UserId and ProductId as incompatible types, even though they’re both strings at runtime. The __brand property creates a unique type signature that prevents accidental substitution.

For better type safety, use unique symbols instead of string literals:

declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: true };

declare const ProductIdBrand: unique symbol;
type ProductId = string & { [ProductIdBrand]: true };

The unique symbol approach is superior because symbols are guaranteed unique, whereas string literals could theoretically collide if you’re not careful with naming.

Implementing Type-Safe Constructors

Raw type assertions with as bypass validation, which defeats the purpose. Instead, create constructor functions that perform runtime validation and return branded types:

declare const EmailBrand: unique symbol;
type Email = string & { [EmailBrand]: true };

function createEmail(value: string): Email {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  
  if (!emailRegex.test(value)) {
    throw new Error(`Invalid email: ${value}`);
  }
  
  return value as Email;
}

// Valid email
const userEmail = createEmail("user@example.com"); // Email

// Runtime error thrown
const invalidEmail = createEmail("not-an-email"); // throws

For cases where you want to handle errors without exceptions, use a Result type or return Email | null:

function parseEmail(value: string): Email | null {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(value) ? (value as Email) : null;
}

const email = parseEmail(userInput);
if (email) {
  sendWelcomeEmail(email); // Type-safe
}

Here’s a numeric example with range validation:

declare const PositiveNumberBrand: unique symbol;
type PositiveNumber = number & { [PositiveNumberBrand]: true };

function createPositiveNumber(value: number): PositiveNumber {
  if (value <= 0) {
    throw new Error(`Expected positive number, got ${value}`);
  }
  return value as PositiveNumber;
}

function calculateDiscount(price: PositiveNumber, rate: PositiveNumber): number {
  return price * rate;
}

const price = createPositiveNumber(100);
const rate = createPositiveNumber(0.15);
calculateDiscount(price, rate); // OK

calculateDiscount(price, -0.5); // Compile error: number not assignable to PositiveNumber

Practical Use Cases

Preventing Currency Confusion

Financial applications must never mix currencies. Branded types make this impossible:

declare const USDBrand: unique symbol;
declare const EURBrand: unique symbol;

type USD = number & { [USDBrand]: true };
type EUR = number & { [EURBrand]: true };

function usd(amount: number): USD {
  return amount as USD;
}

function eur(amount: number): EUR {
  return amount as EUR;
}

function processPayment(amount: USD): void {
  // Process USD payment
}

const priceInUSD = usd(100);
const priceInEUR = eur(85);

processPayment(priceInUSD); // OK
processPayment(priceInEUR); // Compile error

Database ID Safety

Prevent mixing IDs from different tables:

declare const UserIdBrand: unique symbol;
declare const PostIdBrand: unique symbol;
declare const CommentIdBrand: unique symbol;

type UserId = string & { [UserIdBrand]: true };
type PostId = string & { [PostIdBrand]: true };
type CommentId = string & { [CommentIdBrand]: true };

interface Post {
  id: PostId;
  authorId: UserId;
  title: string;
}

interface Comment {
  id: CommentId;
  postId: PostId;
  authorId: UserId;
  content: string;
}

function getPost(postId: PostId): Post {
  return database.posts.findById(postId);
}

function getComment(commentId: CommentId): Comment {
  return database.comments.findById(commentId);
}

const postId = "post_123" as PostId;
const commentId = "comment_456" as CommentId;

getPost(postId); // OK
getPost(commentId); // Compile error: prevents wrong ID type

This pattern is invaluable in large codebases where IDs are passed through multiple layers. The compiler ensures you never accidentally query the wrong table.

Advanced Patterns

Generic Branded Type Utility

Create a reusable utility type for branding:

declare const __brand: unique symbol;
type Brand<K> = { [__brand]: K };
type Branded<T, K> = T & Brand<K>;

// Usage
type UserId = Branded<string, 'UserId'>;
type ProductId = Branded<string, 'ProductId'>;
type PositiveNumber = Branded<number, 'PositiveNumber'>;
type Email = Branded<string, 'Email'>;

This generic approach reduces boilerplate when creating many branded types.

Multiple Brands

Combine multiple brands for complex validation:

type NonEmpty = Branded<string, 'NonEmpty'>;
type Trimmed = Branded<string, 'Trimmed'>;
type ValidatedInput = NonEmpty & Trimmed;

function validateInput(value: string): ValidatedInput {
  const trimmed = value.trim();
  if (trimmed.length === 0) {
    throw new Error('Input cannot be empty');
  }
  return trimmed as ValidatedInput;
}

Integration with Zod

Combine branded types with runtime validation libraries:

import { z } from 'zod';

declare const EmailBrand: unique symbol;
type Email = string & { [EmailBrand]: true };

const EmailSchema = z.string().email().transform(val => val as Email);

function processEmail(email: Email): void {
  // Email is guaranteed to be valid
}

const result = EmailSchema.safeParse("user@example.com");
if (result.success) {
  processEmail(result.data); // Type is Email
}

Trade-offs and Best Practices

When to use branded types:

  • Domain IDs that must never be confused (user IDs, product IDs)
  • Values with validation rules (emails, URLs, positive numbers)
  • Units of measurement (USD vs EUR, meters vs feet)
  • API boundaries where type safety is critical

When to avoid:

  • Simple internal functions with obvious context
  • Performance-critical hot paths (though the runtime cost is zero, the cognitive overhead exists)
  • When the team is unfamiliar with the pattern and documentation is lacking

Best practices:

  1. Always provide constructor functions—never use raw as assertions in application code
  2. Validate at domain boundaries (API inputs, database reads)
  3. Keep brand names descriptive and consistent
  4. Document validation rules in constructor function JSDoc
  5. Use unique symbols for brands, not string literals
  6. Consider exporting only the branded type and constructor, not the brand symbol itself

Testing:

describe('Email branded type', () => {
  it('accepts valid emails', () => {
    expect(() => createEmail('user@example.com')).not.toThrow();
  });

  it('rejects invalid emails', () => {
    expect(() => createEmail('invalid')).toThrow();
  });

  it('prevents type confusion at compile time', () => {
    const email = createEmail('user@example.com');
    const userId: UserId = "user_123" as UserId;
    
    // This won't compile:
    // sendEmail(userId);
  });
});

Conclusion

Branded types bring nominal typing discipline to TypeScript’s structural type system. They prevent entire classes of bugs where semantically different values share the same primitive type. By combining branded types with validation functions, you create type-safe constructors that enforce correctness at both compile time and runtime.

The pattern shines in domain modeling, especially for IDs, validated strings, and units of measurement. While it adds some boilerplate, the payoff in type safety and self-documenting code is substantial.

Start by applying branded types to your most critical domain concepts—user IDs, monetary values, or validated inputs. As your team becomes comfortable with the pattern, expand usage to other areas where primitive obsession causes confusion. The compiler will thank you, and so will the next developer who doesn’t have to debug a subtle ID mix-up in production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.