Topic 10.4
Branded and Nominal Types
In one line
TypeScript compares types by shape, so a UserId and an OrderId that are both numbers are interchangeable. A brand is a fake extra property that makes them different types, and a constructor function is the one door that hands out branded values.
Think of it like this
Two keys with identical teeth cut but different coloured caps: a red cap for the front door, a blue cap for the garage. The lock does not care about the cap, but you can't mix them up, because you look at the colour before you use one. A brand is the coloured cap: it doesn't change how the value works at runtime, it only stops you handing the wrong one over.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Structural typing
- Types are the same if their shapes match, regardless of their names.
- Nominal typing
- Types are the same only if they have the same declared name. TypeScript simulates this with brands.
- Brand
- A fake property added to a type with
&so two otherwise identical types become different. - Phantom property
- A property that exists in the type but never at runtime.
- Brand constructor
- A function that checks a value and returns it with the brand. The only normal way to get a branded value.
- Type assertion (`as`)
- Telling the checker "treat this value as that type" without any runtime check.
- `unique symbol`
- A type that belongs to exactly one declared symbol, so nothing else can match it.
- Parse, don't validate
- Check once where data enters and return a more precise type, instead of re-checking everywhere.
Step by step
01The bug: two ids that are both numbers
A shop has users and orders. Both have a number id. A function loadUser(id: number) will accept an order id without a complaint, because to the checker they're both just number.
The program runs and loads the wrong record. Nothing is red.
02Add a brand
Intersect number with a tiny object type that has a __brand property holding a different text for each id kind.
The two types still behave like numbers when read, but they are no longer assignable to each other.
type UserId = number & { readonly __brand: "UserId" };
type OrderId = number & { readonly __brand: "OrderId" };03One door: the brand constructor
userId(n) checks that n is a positive whole number, throws if not, and returns n as UserId. That as is the only lie in the program, and it is protected by the check above it.
Everywhere else, a UserId in a signature is proof that the check happened.
04What the checker says
loadUser(orderId(7)) fails with a message that walks down to the brands: Type '"OrderId"' is not assignable to type '"UserId"'. loadUser(7) fails too, because a plain number has no brand.
At runtime nothing changed: the code still prints the lines, since the brand was never real. Types are erased.
05Make it generic with a unique symbol
declare const brand: unique symbol; and type Brand<T, B extends string> = T & { readonly [brand]: B } give a reusable helper. Because the key is a unique symbol, it cannot clash with a normal property, and a file that does not import brand cannot write the key. (An as cast can still forge any brand.)
Email, Meters and Feet are then one line each. Meters can't be assigned to Feet, which prevents the famous unit mix-up bugs.
Try it yourself
- 1
Forge a brand
In the first example add
const fake: UserId = { __brand: "UserId" };. Predict: error? Read the message:Type '{ __brand: "UserId"; }' is not assignable to type 'UserId'.followed byType '{ __brand: "UserId"; }' is not assignable to type 'number'.A plain object isn't a number, so it's rejected. Now tryconst fake2 = { __brand: "UserId" } as UserId;and see thatasis allowed to lie. - 2
Add a third brand
Add
type ProductId = Brand<number, "ProductId">in the second example, then write a function that takesMetersand call it with aProductId. Read the error line by line. - 3
Remove the check
Change
userIdso it just returnsn as UserIdwith noif. Everything type-checks, anduserId(-3)no longer throws. The brand is only as trustworthy as its constructor.
Code & diagrams
Two errors, both caught before running. The code still runs because the brand is erased: the wrong id is accepted at runtime, which is why the checker matters. The brand constructor rejects a bad value at runtime too.
Console
(nothing was printed)Expected console output
user #7
user #7
user #7
8
RangeError: bad user id: -3Meters cannot become Feet by accident. An Email is still a string, so toUpperCase works. The last error shows classes with #private fields are nominal on their own.
Console
(nothing was printed)Expected console output
asha@hectal.in 328.084
X@Y.INBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Pass the wrong id
In the first example, call loadUser(o) where o is an OrderId.
Break #2
Pass a plain number
Call loadUser(7).
Myth vs fact
Myth
A branded type exists at runtime.
Fact
No. The brand is a type-only property. The value is a plain number or string, and the console shows 7, not an object.
Myth
Brands make a value safe.
Fact
They only keep kinds apart. A brand constructor that skips its check, or any as UserId elsewhere, creates an unsafe value that looks safe.
Myth
TypeScript has nominal types.
Fact
Not as a feature. Brands, unique symbols and classes with private members are the available simulations.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Brands interact with arithmetic and generics in useful ways:
UserIdextendsnumber, so it can go anywhere a number can, but results of+are plain numbers. AMap<UserId, User>keeps its keys honest, and aRecordkeyed by a branded string works as well. - ▸
A
unique symboltype must be declared withconst(orstatic readonly). Thedeclareform emits nothing, so the helper is free. Libraries such as Zod offer.brand<"UserId">(), where the brand is applied to validated output, matching the parse-don't-validate idea. - ▸
Opaque types in
.d.tsfiles use the same idea so that callers cannot build a value except through your API. Check the error text: brand names make for clear messages, so give them meaningful names, notBrand1.
Remember this
- 1
The problem this solves: Two different ids, or a price in dollars and one in rupees, are both a plain
number, so they can be swapped by mistake and the checker says nothing. TypeScript is structurally typed (Topic 3.5): two types are the same if they have the same shape, whatever they are called. That is why a function that takes a user's id (anumber) happily accepts an order's id (also anumber). In a system with many ids, emails, currencies and units, this is a quiet source of bugs:loadUser(order.id)compiles and returns the wrong thing. A nominal type system (like Java's) would separate them by name. TypeScript has no such feature, so we simulate it. - 2
The trick is a brand: intersect the base type with an object type carrying a made-up property that only exists in the type.
type UserId = number & { readonly __brand: "UserId" }. The value is still a number at runtime, and the brand property never exists, since no code ever writes it. But in the checker,UserIdhas a property__brandwith the text type"UserId", andOrderId's is"OrderId". Those two are incompatible, so neither can be passed for the other. - 3
A plain
numbercan't be passed for aUserIdeither: it lacks the brand property. So how do you get one? Through a brand constructor, a function that is the single door. Inside it you check the value (is it a positive whole number?) and then use a type assertion (n as UserId, Topic 5.6) to hand out the branded type. All the lying is in one place; every other part of the program just trusts the type. This is parse, don't validate: check once at the edge, carry the proof in the type. - 4
A branded value is still its base type when you read from it.
UserIdis anumber, sou + 1works and gives a plainnumber(the brand is lost after arithmetic, which is usually what you want). The same holds for strings: anEmailbrand is still a string with all string methods. - 5
The brand's property name needs care. A plain text name like
__brandcould clash with a real property called__brand, and it looks like an ordinary property to every reader. A tidier brand uses a **unique symbol**:declare const brand: unique symbol;makes a type no other symbol can equal, and{ readonly [brand]: B }uses it as a key, so it can never clash with a text property. If you do not exportbrand, other files cannot even name the key. Thedeclaremeans no runtime value is created, so there is no cost. A generic helperBrand<T, B>then makes any branded type in one line. - 6
Classes with
#privatefields orprivatemembers are nominal by accident (Topic 6.2): two classes with a different private member are not interchangeable, even with the same public shape, so you can also brand with a class. Brands are a convention, not a guarantee:ascan forge one, and nothing exists at runtime. Use them for domain values that bugs actually come from, not for everything.
Explain it without notes
Why can't TypeScript tell a UserId from an OrderId when both are number aliases?
How does a branded type work, and what does it cost at runtime?
Why use a brand constructor instead of writing as UserId all over the code?
What does unique symbol add to the technique?
Practice
Create type Celsius = Brand<number, "Celsius"> and type Fahrenheit = Brand<number, "Fahrenheit"> plus two conversion functions. Show that passing a Celsius value to the Fahrenheit-to-Celsius function is an error.
Write a brand constructor percent(n: number): Percent that accepts only numbers from 0 to 100 and throws otherwise.
Trade-offs
- ↔
Brands stop real bugs (mixed ids and units) at no runtime cost, but every boundary now needs a constructor and a few
ascasts. Brand the values where mix-ups happen, not every string in the program. - ↔
Brands add noise to error messages and need helper functions for JSON input (you must brand after parsing). Pair them with runtime validation at the edge (Topic 13.4).
Done when you can
I can explain why two
numberaliases are interchangeable in TypeScript.I can write a brand with an intersection and a phantom property.
I can write a brand constructor that validates and then asserts.
I can use
unique symbolto build a reusableBrand<T, B>.I can say what a brand costs at runtime (nothing) and what it can't guarantee.