Command Palette

Search for a command to run...

Hectal
PHASE 10Advanced ~20 min· topic 4 of 6

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.

brand.tswhole filets
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. 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 by Type '{ __brand: "UserId"; }' is not assignable to type 'number'. A plain object isn't a number, so it's rejected. Now try const fake2 = { __brand: "UserId" } as UserId; and see that as is allowed to lie.

  2. 2

    Add a third brand

    Add type ProductId = Brand<number, "ProductId"> in the second example, then write a function that takes Meters and call it with a ProductId. Read the error line by line.

  3. 3

    Remove the check

    Change userId so it just returns n as UserId with no if. Everything type-checks, and userId(-3) no longer throws. The brand is only as trustworthy as its constructor.

Code & diagrams

UserId vs OrderId with brand constructors

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: -3
A reusable Brand with unique symbol, and class privacy

Meters 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.IN

Break 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.

terminal
$ Types panel
── what you'll see ──
index.ts(19,22): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.

Break #2

Pass a plain number

Call loadUser(7).

terminal
$ Types panel
── what you'll see ──
index.ts(20,22): error TS2345: Argument of type 'number' is not assignable to parameter of type 'UserId'.

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: UserId extends number, so it can go anywhere a number can, but results of + are plain numbers. A Map<UserId, User> keeps its keys honest, and a Record keyed by a branded string works as well.

  • ▸

    A unique symbol type must be declared with const (or static readonly). The declare form 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.ts files 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, not Brand1.

Remember this

  1. 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 (a number) happily accepts an order's id (also a number). 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. 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, UserId has a property __brand with the text type "UserId", and OrderId's is "OrderId". Those two are incompatible, so neither can be passed for the other.

  3. 3

    A plain number can't be passed for a UserId either: 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. 4

    A branded value is still its base type when you read from it. UserId is a number, so u + 1 works and gives a plain number (the brand is lost after arithmetic, which is usually what you want). The same holds for strings: an Email brand is still a string with all string methods.

  5. 5

    The brand's property name needs care. A plain text name like __brand could 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 export brand, other files cannot even name the key. The declare means no runtime value is created, so there is no cost. A generic helper Brand<T, B> then makes any branded type in one line.

  6. 6

    Classes with #private fields or private members 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: as can forge one, and nothing exists at runtime. Use them for domain values that bugs actually come from, not for everything.

Explain it without notes

01

Why can't TypeScript tell a UserId from an OrderId when both are number aliases?

02

How does a branded type work, and what does it cost at runtime?

03

Why use a brand constructor instead of writing as UserId all over the code?

04

What does unique symbol add to the technique?

Practice

01

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.

02

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 as casts. 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 number aliases 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 symbol to build a reusable Brand<T, B>.

  • I can say what a brand costs at runtime (nothing) and what it can't guarantee.