Branded (Nominal) Types
TypeScript is structurally typed, so `type UserId = string` and `type OrderId = string` are interchangeable to the compiler, which is exactly the bug class you want to prevent. Branded (nominal) types attach a phantom tag so the two stay distinct at compile time without any runtime cost. This snippet covers the basic brand pattern, a parser-style smart constructor that produces a brand from a raw value, and a multi-brand domain model that ties them together.
705 views
20
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function asUserId(s: string): UserId {
return s as UserId;
}
function loadUser(id: UserId) {
return `loaded ${id}`;
}
const u = asUserId('u_123');
console.log(loadUser(u));The trick is the intersection with { readonly __brand: B }: the structural part stays compatible with the underlying primitive, but the phantom property makes two brands incompatible because their __brand literal types differ. The __brand field never exists at runtime; it is purely a compile-time marker. Wrapping construction in asUserId is the convention so the cast happens in exactly one place. After that point, loadUser cannot accept a raw string or an OrderId, which catches the entire family of mixed-up-id bugs.
type Brand<T, B> = T & { readonly __brand: B };
type Email = Brand<string, 'Email'>;
function parseEmail(value: string): Email | null {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return null;
return value as Email;
}
function sendWelcome(to: Email) {
return `welcome to ${to}`;
}
const ok = parseEmail('[email protected]');
const bad = parseEmail('not-an-email');
if (ok !== null) console.log(sendWelcome(ok));
console.log(`bad parsed to ${String(bad)}`);Pairing brands with parser-style constructors gives you 'parse, do not validate': any value of the brand type has already been checked against the regex (or schema), so downstream code can trust the shape without re-running the validation. Returning Email | null forces the caller to handle invalid inputs at the call site, which is far easier to catch in review than a thrown exception buried deep in a stack. The value as Email cast is the one place where the compiler trusts the runtime check; all other code in the codebase consumes the brand without casts.
type Brand<T, B> = T & { readonly __brand: B };
type UserIdM = Brand<string, 'UserId'>;
type ProductIdM = Brand<string, 'ProductId'>;
type Cents = Brand<number, 'Cents'>;
function asUserIdM(s: string): UserIdM { return s as UserIdM; }
function asProductIdM(s: string): ProductIdM { return s as ProductIdM; }
function asCents(n: number): Cents { return Math.round(n) as Cents; }
interface OrderLine {
user: UserIdM;
product: ProductIdM;
price: Cents;
}
function quote(line: OrderLine): string {
return `user ${line.user} buys ${line.product} for ${line.price} cents`;
}
const line = { 'user': asUserIdM('u_1'), 'product': asProductIdM('p_1'), 'price': asCents(199) } as OrderLine;
console.log(quote(line));The real power of brands shows up across a domain: each entity id, each money type, each unit gets its own brand, and the compiler refuses to swap them. Cents versus a raw number catches the classic 'I passed dollars where cents were expected' bug at the type level, with zero runtime overhead. Constructors handle any normalisation up front (Math.round for Cents), so downstream code does not duplicate that logic. This pattern scales to dozens of brands without ceremony and is the lowest-friction way to encode units, currencies, and id namespaces in a TypeScript codebase.
