Lesson 26 · Module 4.3 — Library-grade patterns

Branded types: faking nominal typing on purpose

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: '…' }.

The reading move

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.

The pattern

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.

Getting a value into a brand

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 — id is just the string "u_123". The __brand field is purely a compile-time tag. Same phantom idea as the builder in 4.2, used here to separate types rather than accumulate one.

A variant you'll also see: unique symbol

Some 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 brand is a promise, not a check

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

Check yourself

Recall, don't re-read. (Answers reveal on click.)

Why is a plain string rejected where a UserId = string & { __brand: 'UserId' } is required?
What does the __brand field compile to in the emitted JavaScript?
Why funnel branded values through a single toUserId constructor?

Primary source

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.

Try it yourself

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.