ESC

Type to search the knowledge base.

Branded Types for IDs

Nominal-style UserId vs OrderId in TypeScript — prevent ID mixups at compile time with brands, parsers, and form boundaries.

advanced4 min read
  • typescript
  • branded-types

Structural typing means string is string. A user id and an order id are both strings at runtime, so this compiles and ships bugs:

function refund(orderId: string) { /* … */ }
function ban(userId: string) { /* … */ }

const userId = 'usr_1';
const orderId = 'ord_9';
refund(userId); // oops — wrong domain, still type-checks

Branded types (opaque / nominal tags) make those two strings incompatible in the type system without changing runtime representation.

Docs: TypeScript issue discussions / handbook patterns for branding, community pattern often attributed to “branded types” / Flavoring.

The brand pattern

declare const __brand: unique symbol;

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

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

UserId is still a string at runtime. At compile time it carries a phantom property the compiler treats as distinct.

function asUserId(raw: string): UserId {
  // validate format if needed
  return raw as UserId;
}

function asOrderId(raw: string): OrderId {
  return raw as OrderId;
}

function refund(orderId: OrderId) {
  console.log(orderId);
}

const uid = asUserId('usr_1');
const oid = asOrderId('ord_9');
refund(oid); // ok
// refund(uid); // error: UserId not assignable to OrderId

The cast inside asUserId is the only place you claim a bare string is a UserId. Everywhere else stays safe.

Validation at the boundary

Brands without validation are theater. Pair with parsers:

const USER_ID = /^usr_[a-z0-9]+$/i;

function parseUserId(raw: unknown): UserId {
  if (typeof raw !== 'string' || !USER_ID.test(raw)) {
    throw new Error('invalid user id');
  }
  return raw as UserId;
}

Or with Zod:

import { z } from 'zod';

const UserIdSchema = z
  .string()
  .regex(/^usr_/)
  .transform((s) => s as UserId);

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

See runtime validation with schemas.

Where brands pay off in frontend apps

  1. Route params — params.userId vs params.orgId.
  2. React Query / SWR keys — ['user', userId] vs mixing ids in cache keys.
  3. Analytics / logging — never log an order id as user_id.
  4. Multi-tenant APIs — TenantId vs ProjectId.
  5. Currency / minor units — Cents vs bare number (brand numbers the same way).
type Cents = Brand<number, 'Cents'>;

function formatUsd(cents: Cents): string {
  return `$${(cents / 100).toFixed(2)}`;
}

function cents(n: number): Cents {
  if (!Number.isInteger(n)) throw new Error('cents must be integer');
  return n as Cents;
}

Flavoring vs full brands

Some codebases use a lighter “flavor”:

type UserId = string & { readonly __flavor?: 'UserId' };

Optional phantom property makes brands easier to construct accidentally. Full unique-symbol brands are stricter. Pick one convention and document the constructors.

Interop with JSON and APIs

JSON has no brands. After fetch:

type UserDto = { id: string; name: string };
type User = { id: UserId; name: string };

function toUser(dto: UserDto): User {
  return { id: parseUserId(dto.id), name: dto.name };
}

Serialize by using the string value directly — brands erase:

function toDto(user: User): UserDto {
  return { id: user.id, name: user.name }; // UserId assignable to string
}

UserId → string is usually fine (widening). string → UserId must go through a constructor.

Footguns

Mistake Result
Casting everywhere as UserId Brand is useless
Branding without format checks Garbage in, branded garbage out
Over-branding every string field API noise; brand identifiers and units that mix up
Forgetting brands at form submit UI still mixes raw strings
// Bad: brand at call site without parse
refund(orderIdInput as OrderId);

Interview angle

“TypeScript is structural, so two string IDs are interchangeable. Branding intersects a string with a unique phantom tag so UserId isn’t assignable to OrderId. Runtime is still string; safety lives in parse/construct functions at trust boundaries.”

Further reading

Related guides