Lesson 26 · Module 4.3 — Library-grade patterns
TypeScript is structural — any string is interchangeable with any other
string. Usually a feature; sometimes a bug, because a UserId and a
raw user-typed string are not the same thing. A branded type intersects a
unique fake field onto the base type so the compiler treats it as distinct. You'll see this all
over library code as string & { readonly __brand: '…' }.
When you see a base type intersected with an object that has a weird never-assigned key
like __brand, __type, or a unique symbol — that's a
brand. The object field is a phantom (no runtime value); its only job is to
make the type nominally distinct so a plain base value can't be passed in.
A brand is the base type & an object carrying a marker the rest of the
program never produces:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
const raw: string = "abc";
getUser(raw); // ❌ string is missing the __brand field → not a UserId
Why does the raw string fail? Structurally, UserId requires a
__brand property and a plain string doesn't have one — so it isn't a
subtype. And because each brand uses a different literal ('UserId' vs
'OrderId'), a UserId can't be passed where an OrderId is
expected either. You've manufactured nominal types out of structural parts.
No literal ever satisfies __brand at runtime, so you mint branded values
through a single asserting "constructor". That's the deliberate choke point — validation lives
there, and nowhere else can fabricate the brand:
function toUserId(s: string): UserId {
// run real validation here (length, prefix, regex…)
return s as UserId; // the ONE sanctioned cast
}
const id = toUserId("u_123"); // id: UserId — now accepted by getUser
The brand never exists at runtime —idis just the string"u_123". The__brandfield is purely a compile-time tag. Same phantom idea as the builder in 4.2, used here to separate types rather than accumulate one.
unique symbolSome libraries brand with a declared unique symbol key instead of a string
literal, to make the brand truly unforgeable (no one can re-declare the symbol):
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type Email = Brand<string, 'Email'>; // reusable helper
Read it the same way: a base type intersected with an inaccessible marked field. The helper
Brand<T, B> just makes the pattern reusable across many branded types.
The cast in toUserId is unchecked at the type level — TS believes you. If you
skip the validation, the brand is a lie. Branding gives you a place to enforce
invariants and a guarantee they were enforced somewhere; it doesn't run the check for
you. Read a branded value as "someone promised this passed toUserId".
Recall, don't re-read. (Answers reveal on click.)
string rejected where a UserId = string & { __brand: 'UserId' } is required?__brand field compile to in the emitted JavaScript?toUserId constructor?TypeScript has no built-in nominal keyword; the official discussion lives in the handbook's
note on Creating Types from Types
and the long-standing nominal-typing issue #202.
A clear treatment is Total TypeScript's
Branded Types. Best way to
feel it: paste the UserId/OrderId pair into the
TS Playground and try passing one where the
other is expected.
Spot a type like T & { readonly [tag]: '…' } and unsure what it buys?
Read it as a brand and work out what it keeps apart. Next lesson (4.4) switches
domains to React's three renderable types — ReactNode vs ReactElement
vs JSX.Element.