Branded and Nominal Types
Making two structurally identical types incompatible on purpose — validated input, identifiers, units — and the ergonomics of constructing and unwrapping them.
Making two structurally identical types incompatible on purpose — validated input, identifiers, units — and the ergonomics of constructing and unwrapping them.
An incident report reads: a scheduled job used a value in
seconds where the API expected milliseconds, so every retry
waited a thousand times too long and the queue backed up for six
hours. Both values were number. Nothing could have caught it.
Structural typing means two identical shapes are one type — the
mechanism that makes this language flexible, and the reason
Metres and Feet are interchangeable. By the end of this
lesson you will make selected types deliberately incompatible,
know what that costs, and know where it is worth paying.
type Metres = number;
type Feet = number;
const distance: Metres = 100;
const height: Feet = distance; // fine - and wrongA type alias is a name, not a distinct type. Both are number,
so they are assignable in both directions.
The same for identifiers:
type CustomerId = string;
type AccountId = string;
function transfer(from: AccountId, to: AccountId): void { ... }
transfer(customerId, accountId); // compilesEvery identifier in a program is a string, so the checker cannot tell one from another and swapping arguments is invisible.
A brand is a property that exists only in the type:
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type AccountId = Brand<string, "AccountId">;
type CustomerId = Brand<string, "CustomerId">;function transfer(from: AccountId, to: AccountId): void { ... }
transfer(customerId, accountId);
// ~~~~~~~~~~ 'CustomerId' is not assignable to 'AccountId'The intersection adds a property no runtime value has, so the
types stop being structurally identical. At runtime an
AccountId is an ordinary string — the brand is erased with
everything else, per the runtime-versus-compile-time lesson.
A unique symbol for the key matters. A string key like
__brand can be collided with by another library using the same
convention, and it appears in autocomplete. A symbol declared
unique cannot be produced elsewhere.
The cost is that you cannot create a branded value by assignment — which is the point:
Bad — asserting the brand wherever it is needed.
function handler(request: Request): void {
const id = request.params.id as AccountId;
transfer(id, target);
}Good — one checked constructor.
const ACCOUNT_ID = /^acct_[a-z0-9]{16}$/;
export function toAccountId(value: string): AccountId {
if (!ACCOUNT_ID.test(value)) {
throw new ValidationError(`not an account id: ${value}`);
}
return value as AccountId;
}
function handler(request: Request): void {
transfer(toAccountId(request.params.id), target);
}The first version has a brand that means nothing. Every as AccountId is an unchecked claim, so a customer id, an empty
string or a SQL fragment becomes an AccountId because somebody
wrote it down — and the type now provides false confidence,
which is worse than no type.
The second contains the assertion in one function behind a real
check. Everywhere else, having an AccountId means something
verified it — which is the parse-at-the-boundary rule from the
practice course, expressed in the type.
Keep the constructor and the type together, and do not export
the brand itself. If consumers can write value as AccountId,
they will.
The non-throwing variant pairs with the Result type:
export function parseAccountId(value: string): AccountId | null {
return ACCOUNT_ID.test(value) ? (value as AccountId) : null;
}Four categories, and they share a property: confusing two values causes real damage and nothing else can catch it.
Units
Seconds and Milliseconds, Cents and Pounds, Metres
and Feet. The opening incident is this category, and it is
the strongest case — the mistake is invisible and the
consequence is arbitrary.
Identifiers passed together
Any function taking two ids of different kinds. Branding turns a swapped-argument bug into a compile error.
Validated values
Email, Url, SafeHtml. The brand carries the proof that
a check happened, so nothing downstream repeats it and
nothing can forget it.
Values with a required lifecycle
A Connection versus an OpenConnection, a Draft versus a
Published. The type records which state a value is in.
The third one is the one worth seeing:
function render(html: SafeHtml): void { ... }
render(userInput); // error
render(sanitise(userInput)); // fineThat is a security control expressed as a type — the only way to
call render is through the sanitiser.
Where it is not worth it: a value used in one place, a type that
never travels, or anything where confusing two values is
harmless. The ceremony is real — a constructor, a check, and
as in one place — and it should buy something.
Branding a class needs no trick, since classes are nominal enough via a private field:
class AccountId {
readonly #brand = true;
constructor(readonly value: string) { ... }
}That is genuinely nominal at runtime, and it costs an object
allocation and .value at every use — usually not worth it for
an identifier, sometimes right for something with behaviour.
Multiple brands compose, since they are intersections:
type Validated<T> = T & { readonly [validated]: true };
type Trimmed<T> = T & { readonly [trimmed]: true };
type CleanEmail = Validated<Trimmed<string>>;Branding a number works identically and is where units live:
type Milliseconds = Brand<number, "Milliseconds">;
const timeout: Milliseconds = toMilliseconds(30);Note that arithmetic on branded numbers loses the brand —
a + b where both are Milliseconds produces number. That is
correct, since adding two durations could produce anything, and
it means arithmetic goes inside a helper that re-brands:
function addMs(a: Milliseconds, b: Milliseconds): Milliseconds {
return (a + b) as Milliseconds;
}THE PROBLEM
type Metres = number; type Feet = number;
an alias is a NAME, not a distinct type - both are number
every identifier is a string, so swapping two is invisible
THE BRAND
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type AccountId = Brand<string, "AccountId">;
a unique symbol, not a "__brand" string key - no collisions,
and it stays out of autocomplete
erased at runtime; it is an ordinary string
CONSTRUCTING
ONE constructor, containing the single `as`, behind a real check
everywhere else, having the type MEANS something verified it
`as AccountId` scattered at call sites gives a brand that means
nothing, and false confidence is worse than no type
do not export the brand symbol
WORTH IT FOR
units Seconds vs Milliseconds, Cents vs Pounds
identifiers passed together to the same function
validated values Email, Url, SafeHtml - the brand carries the proof
lifecycle states Draft vs Published, Connection vs OpenConnection
the test: does confusing two cause real damage that nothing
else can catch?
NOT WORTH IT
a value used in one place, or that never travels
anything where the confusion is harmless
VARIATIONS
a class with a #private field is nominal at runtime - costs an
allocation and .value everywhere
brands compose: Validated<Trimmed<string>>
arithmetic loses the brand - wrap it in a helper that re-brands
SERIALISATION
a round trip returns an UNBRANDED value
parse it back through the constructor; do not assertYou can now make two structurally identical types deliberately incompatible, and — the part that decides whether it is worth anything — put the single assertion behind a real check. A brand without a checked constructor is decoration; with one, it carries a guarantee everywhere the value goes.
Next is The Compiler API and Custom Tooling, which moves from describing your code to reading it programmatically: codemods, custom lint rules, and generating types from a source of truth.
Before you move on, find a function taking two parameters of the same primitive type where swapping them would be a bug. Brand one of them and see whether any existing call site fails. In a codebase of reasonable size, one of them usually does.