Skip to content

Branded Types and Nominal Typing

Use branded and nominal types for IDs and tokens, when brands prevent real mixups, and when they are ceremony TypeScript does not need.

5 min read
Branded Types
Nominal Typing
TypeScript
IDs
Design Tokens
Frontend

Generics and conditional/template literal types covered reusable components and prop relationships. Structural typing still has a blind spot: string is string. A user id, an order id, and a Stripe-looking token all type-check interchangeably until production joins the wrong table.

Branded types are how I recover a little nominal safety without leaving TypeScript for another language.

The mixup that earns a brand

ts
// packages/domain/src/ids.ts, illustrative declare const UserIdBrand: unique symbol; declare const OrderIdBrand: unique symbol; export type UserId = string & { readonly [UserIdBrand]: void }; export type OrderId = string & { readonly [OrderIdBrand]: void }; export function UserId(value: string): UserId { if (!value.startsWith('usr_')) { throw new TypeError(`Invalid UserId: ${value}`); } return value as UserId; } export function OrderId(value: string): OrderId { if (!value.startsWith('ord_')) { throw new TypeError(`Invalid OrderId: ${value}`); } return value as OrderId; }
ts
function refundOrder(orderId: OrderId, actor: UserId) { // ... } const user = UserId('usr_ada'); const order = OrderId('ord_99'); refundOrder(order, user); // ok // @ts-expect-error, cannot pass user where order is required refundOrder(user, order);

The brand is a compile-time ghost. Runtime is still a string. The constructor is where I put cheap validation, prefix checks, non-empty, not a full schema. Network boundaries still belong to Zod (or similar), same rule as the conditional-types post.

Tokens and CSS: brands vs template literals

The conditional-types post used template literal types for semantic token unions (bg-${Tone}). Brands solve a different problem: preventing two strings with the same shape from swapping.

ProblemPrefer
primary | danger closed setUnion / template literal
UserId vs OrderId both stringBrand
CSS variable name must match themeUnion of known tokens
Opaque API key you never inspectBrand + constructor
ts
// Don't brand every string, illustrative anti-pattern type ButtonLabel = string & { readonly __label: void };

A button label brand rarely pays off. Mixups are rare; ceremony is constant. I brand identifiers that cross package boundaries and show up in URLs, logs, and cache keys.

Constructor discipline

Brands without constructors are wishes:

ts
// Weak, any string cast sneaks in const id = 'ord_1' as OrderId;

I keep constructors next to the type and ban scattered as OrderId via lint or code review. Parsing from the URL:

ts
// apps/web/src/routes/orders.$orderId.tsx, illustrative const orderId = OrderId(params.orderId); // throws or Result-type if invalid

Invalid ids fail at the edge. Downstream code assumes OrderId is well-formed enough for the prefix contract, not that the row exists (that is a 404 from the query).

When brands are ceremony

Skip brands when:

  1. The value never crosses a function boundary (local variable only).
  2. A single discriminated union already carries the id ({ type: 'user', id }).
  3. The team will immediately cast everywhere "to ship", you have documentation, not safety.
  4. Runtime validation is the real requirement and types would only mirror Zod output, prefer z.infer and a single schema.

Brands shine in shared packages (@acme/domain) consumed by multiple apps. They rot in a one-off screen.

Interop with generics and React

tsx
// packages/ui/src/entity-link.tsx, illustrative type EntityLinkProps<T extends string> = { id: T; hrefFor: (id: T) => string; children: React.ReactNode; }; export function EntityLink<T extends string>({ id, hrefFor, children, }: EntityLinkProps<T>) { return <a href={hrefFor(id)}>{children}</a>; } // Usage preserves the brand through inference <EntityLink id={order} hrefFor={(id) => `/orders/${id}`} />;

Generics plus brands keep the id honest through UI helpers without widening back to string too early. Widen explicitly at the network boundary when the API expects a plain string (it always does).

Result types vs throwing constructors

Throwing in OrderId() is fine at route edges. Inside reducers and shared helpers I sometimes prefer a Result so UI can render a 404 without a try/catch festival:

ts
// packages/domain/src/ids.ts, illustrative export type ParseIdError = { code: 'invalid_prefix'; value: string }; export function parseOrderId( value: string, ): { ok: true; value: OrderId } | { ok: false; error: ParseIdError } { if (!value.startsWith('ord_')) { return { ok: false, error: { code: 'invalid_prefix', value } }; } return { ok: true, value: value as OrderId }; }

Pick one style per boundary. Mixing throw-everywhere with Result-everywhere in the same package teaches nobody.

Failure modes

Branding number for every pixel and millisecond. Noise. Brand currency cents or entity ids; leave width: number alone unless you have a real px/em mixup epidemic.

Runtime-heavy constructors. If UserId() hits the network, it is no longer a type helper, it is a service.

Present-tense TS 6 / tsgo claims. Still wrong for late October 2025 in this series. Brands are a 5.x technique. The TS 6 bridge is a later post in this thread.

Continuity

TypeScript depth thread: generics, conditionals/templates, and brands. Linting returns with Biome first look and migration lessons, then the TS 6 bridge, without treating native TS 7 as today's default toolchain.

Takeaway

Brand ids that cross boundaries and get mixed up as plain strings, skip brands that only decorate labels, and always pair the type with a constructor at the edge.