Branded types: the TypeScript pattern that prevents whole classes of runtime bugs

By Sven Lindqvist · 19 July 20263 views

A developer spends six hours debugging a production incident. The bug: an email notification is sent to the wrong user. The root cause: a function that sends a notification email takes a userId as its first parameter and an orderId as its second. At one call site, the two arguments were swapped. Both are strings. TypeScript saw no problem.

// The function signature
async function sendOrderConfirmation(userId: string, orderId: string): Promise<void>

// The bug — arguments swapped, TypeScript is silent
await sendOrderConfirmation(order.id, user.id);
// Notification sent to the user whose ID matches the orderId string
// That user receives an email confirming a purchase they never made

The code review passed. The tests passed — the tests were only checking that the email was sent, not that it was sent to the correct recipient. TypeScript's type system treats all strings as interchangeable. When the semantic meaning of a string matters — that this string is a user ID, not an order ID, not a session token, not a tenant ID — the type system cannot help without additional information.

This pattern of treating all string (or number) identifiers as equivalent is called primitive obsession. It is one of the most common sources of subtle bugs in large TypeScript codebases. Branded types fix it.

What branded types are

A branded type adds a compile-time marker to a primitive type that distinguishes it from other primitives of the same underlying type. The marker exists only in the type system — at runtime, the value is still a plain string (or number, or whatever the underlying type is). There is no performance overhead and no runtime library required.

// Create branded types with a unique type tag
type UserId = string & { readonly _brand: 'UserId' };
type OrderId = string & { readonly _brand: 'OrderId' };
type ProductId = string & { readonly _brand: 'ProductId' };

// Brand a value — the type assertion is explicit and controlled
function toUserId(id: string): UserId {
  return id as UserId;
}

function toOrderId(id: string): OrderId {
  return id as OrderId;
}

// Now the function signature enforces semantic correctness
async function sendOrderConfirmation(userId: UserId, orderId: OrderId): Promise<void> {
  // ...
}

// TypeScript catches the argument swap at compile time
await sendOrderConfirmation(order.id, user.id);
// Error: Argument of type 'string' is not assignable to parameter of type 'UserId'

// Even with explicit branding, the swap is caught:
await sendOrderConfirmation(toOrderId(order.id), toUserId(user.id));
// Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'

// Correct usage
await sendOrderConfirmation(toUserId(user.id), toOrderId(order.id));

The & { readonly _brand: 'UserId' } intersection adds a property _brand with a literal type to the type. This property does not exist on plain string, so string is not assignable to UserId. Values can only become a UserId through an explicit conversion. That conversion is the assertion boundary — the one place in the code where the developer declares "I know this string is a user ID."

A generic Brand helper

Most teams define a generic helper rather than repeating the intersection pattern for every branded type:

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'>;
type TenantId  = Brand<string, 'TenantId'>;
type SessionId = Brand<string, 'SessionId'>;

// Number brands work the same way
type Milliseconds = Brand<number, 'Milliseconds'>;
type Bytes        = Brand<number, 'Bytes'>;
type Percentage   = Brand<number, 'Percentage'>;

An alternative uses a unique symbol for the brand, which provides slightly stronger guarantees against accidental structural compatibility:

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

declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: true };
// These are guaranteed distinct even if someone creates a Brand<string, 'UserId'>
// using a different mechanism

The unique symbol approach is more robust for library-level code. For application code, the string-tagged Brand<T, B> helper is simpler and sufficient.

Where to apply branding

Database IDs

IDs are the most common application. In any application with multiple entity types, the IDs are all strings (or numbers), and every function that accepts an ID should accept only the specific entity's ID type — not any ID.

type UserId    = Brand<string, 'UserId'>;
type ArticleId = Brand<string, 'ArticleId'>;
type TenantId  = Brand<string, 'TenantId'>;
type CommentId = Brand<string, 'CommentId'>;

// At the data layer, brand values when they come from the database
// Prisma result — the raw id field is 'string'
const user = await db.users.findUnique({ where: { id: rawId } });
if (!user) throw new NotFoundError();

// Brand at the trust boundary — the data layer
const typedUser = {
  ...user,
  id: user.id as UserId,  // Only in the data layer; never scattered through the app
};

// Now every function that accepts a UserId is safe
async function fetchUserOrders(userId: UserId): Promise<Order[]> {
  return db.orders.findMany({ where: { userId } });
}

async function deleteUserAccount(userId: UserId, requestingTenantId: TenantId): Promise<void> {
  // Cannot swap userId and requestingTenantId — different brands
  // ...
}

// Compile error — tenantId is not a UserId
await deleteUserAccount(tenantId, userId);
// Error: Argument of type 'TenantId' is not assignable to parameter of type 'UserId'

Validated strings

Branding documents that a string has passed a validation step. Functions that require validated input can express this requirement in their type signature.

type ValidatedEmail = Brand<string, 'ValidatedEmail'>;
type SanitizedHtml  = Brand<string, 'SanitizedHtml'>;
type SlugifiedString = Brand<string, 'SlugifiedString'>;

function validateEmail(email: string): ValidatedEmail | null {
  const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
  return isValid ? (email as ValidatedEmail) : null;
}

function sanitizeHtml(raw: string): SanitizedHtml {
  // DOMPurify.sanitize or equivalent
  return DOMPurify.sanitize(raw) as SanitizedHtml;
}

function slugify(text: string): SlugifiedString {
  return text
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]/g, '') as SlugifiedString;
}

// Functions requiring validated/sanitized input are explicit about it
async function sendEmail(to: ValidatedEmail, subject: string, body: string): Promise<void> {
  /* ... */
}

function renderUserBio(html: SanitizedHtml): JSX.Element {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// Unvalidated string cannot be passed — compile error
sendEmail(userInput.email, 'Welcome', '...');
// Error: Argument of type 'string' is not assignable to parameter of type 'ValidatedEmail'

// Must validate first
const validEmail = validateEmail(userInput.email);
if (validEmail) {
  sendEmail(validEmail, 'Welcome', '...');  // OK
}

// Cannot render unsanitized HTML in a function that expects sanitized
renderUserBio(userInput.bio);
// Error: Argument of type 'string' is not assignable to parameter of type 'SanitizedHtml'
renderUserBio(sanitizeHtml(userInput.bio));  // OK

The brand turns security properties — "this HTML has been sanitized," "this email has been validated" — into compiler-checked constraints. An XSS bug caused by passing unsanitized HTML to dangerouslySetInnerHTML becomes a compile error.

Physical units and domain quantities

In engineering, scientific, and financial applications, mixing incompatible units causes real problems. Brands prevent accidental unit confusion:

type Meters     = Brand<number, 'Meters'>;
type Kilograms  = Brand<number, 'Kilograms'>;
type Celsius    = Brand<number, 'Celsius'>;
type Fahrenheit = Brand<number, 'Fahrenheit'>;
type USD        = Brand<number, 'USD'>;
type EUR        = Brand<number, 'EUR'>;

function calculateShippingCost(weightKg: Kilograms, distanceM: Meters): USD {
  return (weightKg * distanceM * 0.001) as USD;
}

const weight   = 5 as Kilograms;
const distance = 1000 as Meters;

// Cannot swap arguments — different brands
calculateShippingCost(distance, weight);
// Error: Argument of type 'Meters' is not assignable to parameter of type 'Kilograms'

calculateShippingCost(weight, distance);  // OK — returns USD

// Currency mixing is caught at compile time
function convertUsdToEur(amount: USD, rate: number): EUR {
  return (amount * rate) as EUR;
}

const price: USD = 100 as USD;
const eurPrice: EUR = convertUsdToEur(price, 0.92);

// Adding USD and EUR directly would require a specific conversion function
// because they are different types even though both are 'number' at runtime
function addPrices(a: USD, b: USD): USD {
  return (a + b) as USD;
}
addPrices(price, eurPrice);
// Error: Argument of type 'EUR' is not assignable to parameter of type 'USD'

Time and duration

Time values are particularly prone to confusion — milliseconds vs. seconds, Unix timestamps vs. relative durations:

type UnixTimestampMs = Brand<number, 'UnixTimestampMs'>;
type DurationMs      = Brand<number, 'DurationMs'>;
type UnixTimestampS  = Brand<number, 'UnixTimestampS'>;

function now(): UnixTimestampMs {
  return Date.now() as UnixTimestampMs;
}

function addDuration(timestamp: UnixTimestampMs, duration: DurationMs): UnixTimestampMs {
  return (timestamp + duration) as UnixTimestampMs;
}

const FIVE_MINUTES = (5 * 60 * 1000) as DurationMs;

const sessionExpiry = addDuration(now(), FIVE_MINUTES);

// Cannot accidentally pass a Unix timestamp in seconds where milliseconds expected
const badExpiry = addDuration(now(), (Date.now() / 1000) as UnixTimestampS);
// Error: Argument of type 'UnixTimestampS' is not assignable to parameter of type 'DurationMs'

The ergonomic cost and how to manage it

The compile-time benefit comes with a small ergonomic cost: every value must be explicitly branded at some point. Raw strings from APIs, databases, and form inputs must be converted before they can be used as branded types. This creates a decision point that is valuable: where are values trusted?

The recommended pattern is to brand at the outermost trust boundary and propagate the branded type inward:

// API response layer — brand as values enter the application
interface UserApiResponse {
  id: string;       // Raw from API
  email: string;    // Raw from API
  tenantId: string; // Raw from API
}

interface User {
  id: UserId;       // Branded after validation
  email: ValidatedEmail;
  tenantId: TenantId;
}

function fromApiResponse(raw: UserApiResponse): User {
  const validEmail = validateEmail(raw.email);
  if (!validEmail) throw new Error(`Invalid email from API: ${raw.email}`);

  return {
    id: raw.id as UserId,
    email: validEmail,
    tenantId: raw.tenantId as TenantId,
  };
}

After this conversion, the User type carries branded IDs throughout the application. Every function that accepts a UserId or ValidatedEmail will be satisfied without additional assertions. The assertion exists only at the boundary.

For Zod schemas, the conversion integrates naturally:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().transform(s => s as UserId),
  email: z.string().email().transform(s => s as ValidatedEmail),
  tenantId: z.string().transform(s => s as TenantId),
  createdAt: z.string().datetime().transform(s => new Date(s)),
});

type User = z.infer<typeof UserSchema>;
// User.id is UserId, not string
// User.email is ValidatedEmail, not string

async function fetchUser(id: UserId): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return UserSchema.parse(await response.json());
}

Composing brands with other type system patterns

Brands work well alongside other TypeScript patterns. Combined with discriminated unions, they express both the category of a value and its semantic identity:

type PaymentEvent =
  | { type: 'charge'; amount: USD; customerId: CustomerId; idempotencyKey: IdempotencyKey }
  | { type: 'refund'; amount: USD; chargeId: ChargeId; reason: string }
  | { type: 'payout'; amount: USD; accountId: AccountId; estimatedArrival: Date };

type IdempotencyKey = Brand<string, 'IdempotencyKey'>;
type ChargeId       = Brand<string, 'ChargeId'>;
type CustomerId     = Brand<string, 'CustomerId'>;
type AccountId      = Brand<string, 'AccountId'>;

// The charge handler can only be given the fields that belong to a charge event
async function processCharge(
  amount: USD,
  customerId: CustomerId,
  idempotencyKey: IdempotencyKey
): Promise<ChargeId> {
  const response = await stripeClient.charges.create({
    amount: amount * 100,  // Stripe uses cents
    customer: customerId,
    idempotency_key: idempotencyKey,
  });
  return response.id as ChargeId;
}

// Cannot pass a ChargeId where a CustomerId is expected — different brands
await processCharge(amount, chargeId, idempotencyKey);
// Error: Argument of type 'ChargeId' is not assignable to parameter of type 'CustomerId'

Brands also compose with validation libraries. Zod's .transform() allows creating branded types at parse time:

const UserIdSchema = z.string().uuid().transform(id => id as UserId);
const OrderIdSchema = z.string().uuid().transform(id => id as OrderId);

const CreateOrderRequestSchema = z.object({
  userId: UserIdSchema,    // Validates UUID format and brands as UserId
  items: z.array(z.object({
    productId: z.string().transform(id => id as ProductId),
    quantity: z.number().int().positive(),
  })),
});

type CreateOrderRequest = z.infer<typeof CreateOrderRequestSchema>;
// CreateOrderRequest.userId is UserId — branded and UUID-validated
// Can be passed directly to functions expecting UserId

Common mistakes

Branding at scattered call sites. The value of branding comes from having a single conversion boundary. If as UserId appears throughout the codebase wherever string-to-UserId conversion happens, the brand is not providing real safety — it is just syntactic noise. All brand construction should happen at known boundaries: data layer functions, API response parsers, and validated form handlers.

Using brands as documentation without enforcement. Some teams add brand types to a codebase but do not enable noImplicitAny or do enable broad any usage. When any values flow through the codebase, branded types provide no protection — any is assignable to everything including branded types.

Forgetting to brand in test fixtures. Test code often constructs entity objects directly. Without branding in test fixtures, the test code may compile while asserting things that would not be possible in production code:

// Test fixture — missing brands
const testUser = { id: 'user-1', email: '[email protected]' };

// This works in tests because testUser.id is 'string', not 'UserId'
// but it means tests don't exercise the branded-type paths
await sendOrderConfirmation(testUser.id, order.id);

The fix: define typed test fixtures using the same branded constructors used in production code.

Over-branding simple values. Not every string needs a brand. Brands are valuable when two or more string values could be confused at a call site — multiple ID types, multiple validated-string types, multiple unit types. A string that is only ever used in one context and has no possible confusion partner does not need a brand. Adding brands to every string in a codebase creates ergonomic burden without proportional safety benefit.

Why this matters at scale

The developer who experienced the email incident added branded types to all ID types in her application after the incident. The change flagged 34 call sites where similar functions existed and where argument order was the only distinction between correct and incorrect usage. None of those 34 functions had tests specifically verifying argument order. The type system now enforces correct argument order without tests.

The deeper benefit is that branded types make the structure of safe code visible. When a function signature is sendOrderConfirmation(userId: UserId, orderId: OrderId), a new developer reading the code understands that the distinction between a user ID and an order ID is meaningful enough to warrant a compile-time check. The type signature communicates intent that a comment never enforces.

At the scale of a large codebase — tens of thousands of lines, dozens of developers, years of accumulated changes — primitive obsession is one of the most common sources of subtle, hard-to-detect bugs. Branded types are the TypeScript mechanism that eliminates the class entirely at compile time.

Comments

No comments yet. Be the first!

Sign in to leave a comment.