DEV Community

Atlas Whoff
Atlas Whoff

Posted on

TypeScript Branded Types: Stop Mixing Up IDs at Compile Time

You've done it. I've done it. Everyone building a SaaS has shipped code where a userId got passed where an orderId was expected — and TypeScript said nothing.

Branded types fix this. They cost zero runtime overhead and catch entire categories of bugs before your code runs.

The Problem

function getOrder(userId: string, orderId: string) {
  // TypeScript doesn't care which is which
}

// This compiles fine. It is wrong.
getOrder(order.id, user.id);
Enter fullscreen mode Exit fullscreen mode

Strings are strings. TypeScript's structural type system doesn't distinguish between a userId string and an orderId string — they're the same shape.

Branded Types in Under 10 Lines

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;

// Constructor functions (the only way to create branded values)
const UserId = (id: string): UserId => id as UserId;
const OrderId = (id: string): OrderId => id as OrderId;
const ProductId = (id: string): ProductId => id as ProductId;
Enter fullscreen mode Exit fullscreen mode

Now update the function signature:

function getOrder(userId: UserId, orderId: OrderId) {
  // TypeScript now enforces the distinction
}

const user = { id: UserId('usr_123') };
const order = { id: OrderId('ord_456') };

// ✅ Correct
getOrder(user.id, order.id);

// ❌ Type error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
getOrder(order.id, user.id);
Enter fullscreen mode Exit fullscreen mode

The swap that would've caused a production incident is now a compile error.

Real-World Patterns

Branded numeric types

type Cents = Brand<number, 'Cents'>;
type Dollars = Brand<number, 'Dollars'>;

const Cents = (n: number): Cents => n as Cents;
const Dollars = (n: number): Dollars => n as Dollars;

function charge(amount: Cents) {
  // always in cents, never accidentally dollars
}

const price = Dollars(9.99);
// ❌ Compile error — caught the unit mismatch
charge(price);
Enter fullscreen mode Exit fullscreen mode

Unit confusion in payment code is catastrophic. This makes it impossible.

Validated strings

type Email = Brand<string, 'Email'>;
type Slug = Brand<string, 'Slug'>;

function validateEmail(raw: string): Email {
  if (!raw.includes('@')) throw new Error('Invalid email');
  return raw as Email;
}

function validateSlug(raw: string): Slug {
  return raw.toLowerCase().replace(/[^a-z0-9-]/g, '-') as Slug;
}

// Now only validated values can flow through your system
function sendEmail(to: Email, subject: string) { ... }
Enter fullscreen mode Exit fullscreen mode

The brand encodes that validation has occurred. You get a type-level audit trail.

Database IDs from Prisma/Drizzle

// Auto-brand IDs coming out of your ORM
type UserRecord = {
  id: UserId;
  email: Email;
  stripeCustomerId: Brand<string, 'StripeCustomerId'>;
};

// Map your DB result
function toUserRecord(row: { id: string; email: string; stripe_id: string }): UserRecord {
  return {
    id: UserId(row.id),
    email: validateEmail(row.email),
    stripeCustomerId: row.stripe_id as Brand<string, 'StripeCustomerId'>,
  };
}
Enter fullscreen mode Exit fullscreen mode

Once branded at the boundary, the types flow correctly everywhere downstream.

Handling Arrays and Generic Utilities

// Utilities work fine with brands
const userIds: UserId[] = users.map(u => u.id);
const set = new Set<UserId>(userIds);
const map = new Map<UserId, User>();

// Generic functions accept branded types transparently
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const firstId: UserId | undefined = first(userIds); // ✅ brand preserved
Enter fullscreen mode Exit fullscreen mode

Zod Integration

If you're using Zod for runtime validation, brands compose cleanly:

import { z } from 'zod';

const UserIdSchema = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;

// Parse at the API boundary — branded on success
const userId = UserIdSchema.parse(req.params.userId);
Enter fullscreen mode Exit fullscreen mode

Now your API handler gets a UserId, not a raw string. One parse call, brand for life.

When to Use Branded Types

Yes:

  • Entity IDs (user, order, product, session)
  • Financial units (cents vs dollars, different currencies)
  • Validated string types (email, slug, url)
  • Opaque tokens (JWT, API keys) where you want to prevent logging

Skip it:

  • Simple local variables that never cross function boundaries
  • Performance-critical hot paths (the overhead is zero, but cognitive overhead counts)
  • Teams not comfortable with TypeScript internals

Zero Runtime Cost

This is worth saying clearly: branded types are erased at compile time. Your JavaScript output is identical. There is no wrapper object, no class instantiation, no runtime check — unless you add one intentionally (like validateEmail).

// TypeScript
const id: UserId = UserId('usr_123');

// Compiled JavaScript
const id = 'usr_123';
Enter fullscreen mode Exit fullscreen mode

Pure type safety. No runtime tax.


Ship Faster With Pre-Built Patterns

Branded types are one of dozens of TypeScript patterns baked into the AI SaaS Starter Kit ($99) — pre-wired with Stripe, Supabase, and end-to-end type safety so you don't spend a week on plumbing.

If you want the patterns as standalone skill files for Claude Code, the Ship Fast Skill Pack ($49) includes TypeScript, architecture, and AI integration skills.


Branded types are one of those techniques that feel like overkill until the first time they catch a production bug in code review. Once you start using them for IDs, you won't go back.

What are you branding first?

Top comments (0)