Branded & Nominal Types
TypeScript uses structural typing: two types are compatible if their shapes match, regardless of their names. This is powerful and flexible, but it creates a subtle class of runtime bugs where values of the same underlying type — like two string values representing a user ID and an order ID — can be silently swapped without the compiler catching it.
Branded types (also called nominal types) solve this by attaching a unique "brand" to a primitive type, forcing the compiler to distinguish values that are structurally identical.
The Problem: Structural Typing Bites Back
Consider a simple payment system with three IDs: a UserId, an OrderId, and a ProductId. All three are strings at runtime.
type UserId = string;
type OrderId = string;
type ProductId = string;
function getOrder(userId: UserId, orderId: OrderId) {
console.log(`Fetching order ${orderId} for user ${userId}`);
}
const uid: UserId = "user-42";
const oid: OrderId = "order-99";
// TypeScript is perfectly happy — both are strings
getOrder(oid, uid); // BUG: arguments are swapped, no error!UserId and OrderId are both aliases for string, TypeScript sees them as identical. The compiler cannot detect the argument swap.The Solution: Phantom Brands
A brand is a phantom property — it exists only at the type level and is completely erased at runtime. The technique is to intersect the primitive type with an object type that carries a unique literal property:
// Generic brand utility
type Brand<T, B extends string> = T & { readonly _brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;Now UserId and OrderId are distinct types even though they are both strings at runtime. To create a branded value you use a constructor function:
function toUserId(id: string): UserId {
return id as unknown as UserId;
}
function toOrderId(id: string): OrderId {
return id as unknown as OrderId;
}
function getOrder(userId: UserId, orderId: OrderId) {
console.log(`Fetching order ${orderId} for user ${userId}`);
}
const uid = toUserId("user-42");
const oid = toOrderId("order-99");
getOrder(uid, oid); // OK
getOrder(oid, uid); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'Why as unknown as T?
A direct cast from string to UserId fails because string does not have the _brand property. TypeScript rejects unsafe direct casts. The double cast through unknown is the standard escape hatch — it says "widen to the top type first, then narrow to the target." This is safe as long as the cast is hidden inside a validated constructor function.
// Fails in strict mode — TypeScript considers this unsafe const bad = "x" as UserId; // Works — double cast through unknown const good = "x" as unknown as UserId;
as unknown as BrandedType throughout the codebase defeats the purpose of branding.Adding Runtime Validation
The constructor is the ideal place to validate the raw value before branding it. Once the value passes through and gets its brand, the rest of the codebase can trust it completely:
type Email = Brand<string, 'Email'>;
type Url = Brand<string, 'Url'>;
function toEmail(raw: string): Email {
if (!/^[^@]+@[^@]+.[^@]+$/.test(raw)) {
throw new Error(`Invalid email: "${raw}"`);
}
return raw as unknown as Email;
}
function toUrl(raw: string): Url {
try {
new URL(raw); // built-in URL constructor validates structure
} catch {
throw new Error(`Invalid URL: "${raw}"`);
}
return raw as unknown as Url;
}
function sendNewsletter(email: Email, link: Url) {
console.log(`Sending to ${email} with link ${link}`);
}
const email = toEmail("alice@example.com");
const link = toUrl("https://example.com/news");
sendNewsletter(email, link); // OK
sendNewsletter(link, email); // Error: brands are incompatibleReal-World Brand: Money & Currency
Currency arithmetic is a classic source of bugs. Branded types make it impossible to accidentally add USD and EUR amounts together:
type USD = Brand<number, 'USD'>;
type EUR = Brand<number, 'EUR'>;
const usd = (amount: number): USD => {
if (amount < 0) throw new Error("Amount cannot be negative");
return amount as unknown as USD;
};
const eur = (amount: number): EUR => {
if (amount < 0) throw new Error("Amount cannot be negative");
return amount as unknown as EUR;
};
function addUSD(a: USD, b: USD): USD {
return (a + b) as unknown as USD;
}
const price = usd(29.99);
const tax = usd(2.40);
const shipping = eur(5.00);
const total = addUSD(price, tax); // OK: USD + USD = USD
addUSD(price, shipping); // Error: EUR is not assignable to USDOpaque Types with Unique Symbols
For even stronger isolation, use a unique symbol as the brand key. No two unique symbol declarations are ever equal, so the brand cannot be accidentally replicated by another module:
declare const __userId: unique symbol;
declare const __orderId: unique symbol;
type UserId = string & { readonly [__userId]: never };
type OrderId = string & { readonly [__orderId]: never };
// Constructor usage is identical
function toUserId(id: string): UserId {
return id as unknown as UserId;
}
function toOrderId(id: string): OrderId {
return id as unknown as OrderId;
}unique symbol approach is more verbose but provides the strongest guarantee — the brand cannot be recreated by name-matching in another file.Brands vs. Wrapper Classes
An alternative to phantom brands is wrapping the primitive in a class. Classes use nominal (name-based) typing naturally, so two classes with the same shape are still different types:
class UserId {
constructor(readonly value: string) {
if (!value) throw new Error('UserId cannot be empty');
}
}
class OrderId {
constructor(readonly value: string) {
if (!value) throw new Error('OrderId cannot be empty');
}
}
function getOrder(userId: UserId, orderId: OrderId) { /* ... */ }
// Correctly rejected — classes are nominally typed
getOrder(new OrderId("o-1"), new UserId("u-2")); // ErrorApproach | Runtime cost | Primitive interop | Serialisation |
|---|---|---|---|
Branded type | Zero — erased at compile time | Seamless — still a string/number | Works with JSON.stringify automatically |
Wrapper class | Object allocation per value | Must unwrap with .value | Needs custom toJSON() |
Plain type alias | Zero | Seamless but no safety | Works automatically |
Parse-and-Brand at API Boundaries
Apply brands at the outermost layer (API responses, database rows, form inputs) and let them flow inward. The rest of your code can trust the brands without re-validating:
interface RawApiUser {
id: string;
email: string;
createdAt: string;
}
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
interface User {
id: UserId;
email: Email;
createdAt: Date;
}
function parseUser(raw: RawApiUser): User {
return {
id: raw.id as unknown as UserId,
email: raw.email as unknown as Email,
createdAt: new Date(raw.createdAt),
};
}
async function fetchUser(id: UserId): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const raw: RawApiUser = await res.json();
return parseUser(raw);
}
// Only a UserId can be passed — a plain string is rejected
fetchUser("user-42"); // Error: string is not UserId
fetchUser(toUserId("user-42")); // OKBranded Types for Units of Measure
type Metres = Brand<number, 'Metres'>;
type Kilograms = Brand<number, 'Kilograms'>;
type Seconds = Brand<number, 'Seconds'>;
const metres = (n: number) => n as unknown as Metres;
const kilograms = (n: number) => n as unknown as Kilograms;
const seconds = (n: number) => n as unknown as Seconds;
function speed(distance: Metres, time: Seconds): number {
return distance / time;
}
speed(metres(100), seconds(9.58)); // OK
speed(metres(100), kilograms(70)); // Error: Kilograms is not SecondsSummary
TypeScript is structurally typed — same shapes are interchangeable by default
Branded types add a phantom property to distinguish structurally identical types
The brand is erased at compile time — zero runtime overhead
Always encapsulate the cast inside a constructor function with optional validation
Use
as unknown as Tfor the cast — never cast directlyApply brands at API / DB boundaries and let them flow inward ("parse, don't validate")
Use
unique symbolbrands for maximum isolation across modulesWrapper classes achieve nominal typing with runtime cost — choose based on your needs