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; }
tsfunction 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.
| Problem | Prefer |
|---|---|
primary | danger closed set | Union / template literal |
UserId vs OrderId both string | Brand |
| CSS variable name must match theme | Union of known tokens |
| Opaque API key you never inspect | Brand + 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:
- The value never crosses a function boundary (local variable only).
- A single discriminated union already carries the id (
{ type: 'user', id }). - The team will immediately cast everywhere "to ship", you have documentation, not safety.
- Runtime validation is the real requirement and types would only mirror Zod
output, prefer
z.inferand 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.