Branded Types for IDs
Nominal-style UserId vs OrderId in TypeScript — prevent ID mixups at compile time with brands, parsers, and form boundaries.
- 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
- Route params —
params.userIdvsparams.orgId. - React Query / SWR keys —
['user', userId]vs mixing ids in cache keys. - Analytics / logging — never log an order id as
user_id. - Multi-tenant APIs —
TenantIdvsProjectId. - Currency / minor units —
Centsvs barenumber(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.”
Related on this site
- unknown vs any
- Runtime validation with schemas
- Typing fetch responses
- Type assertions safely
- Literal types
Further reading
Related guides
- Basic Types and AnnotationsPrimitives, arrays, objects, and function annotations TypeScript actually checks — plus where inference is enough and where it is not.
- Conditional Types IntroT extends U ? X : Y — how TypeScript picks types from conditions, distributes over unions, and powers utility types you already use.
- Declaration Files and DefinitelyTypedHow .d.ts files describe JS to TypeScript, when to use @types packages, module augmentation, and writing minimal ambient types for untyped libs.
- Discriminated Unions for UI StateModel loading, success, and error as mutually exclusive variants so TypeScript and your UI cannot show impossible states.
- Enums vs Union TypesWhen TypeScript enums help, when they hurt, and why string literal unions plus const objects win for most frontend code.