skip to content

Branded and Nominal-Emulation Patterns

When structural typing lets a `UserId` and an `OrderId` swap places, you add a phantom brand so the two stop matching. Interviewers ask this to see whether you can defend a domain invariant — a validated email, a sanitized string — in the type system instead of by convention.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In TypeScript, `type UserId = string` and `type OrderId = string` are two separate aliases, yet a UserId value can be passed to a function expecting an OrderId. Why does that compile, and how does a branded type turn it into an error?

level: middleimportance: must knowfreq 55%

answer

  1. aliases rename, they do not create
  2. the checker compares shape, not name
  3. give it a member string cannot have
  4. intersection with a phantom marker property
  5. string & { __brand: 'UserId' }

basics

~20 s

A type alias names a type, it does not create one: both aliases are exactly string, and TypeScript compares types by structure. Branding intersects one with a phantom property, string & { __brand: 'UserId' }, that a plain string does not have.

solid answer

~50 s

`type UserId = string` introduces a *name*, not a distinct type — after resolution both aliases are literally `string`, and TypeScript's assignability is structural, so nothing distinguishes them. The fix is to give each one a shape a plain string cannot have: `type UserId = string & { readonly __brand: 'UserId' }`. The intersection keeps every `string` member, so `.toUpperCase()` and template literals still work, but a bare `string` is no longer assignable to `UserId`, and `UserId` is not assignable to `OrderId` because their `__brand` literal types differ. The brand property never exists at runtime — the intersection is erased with the rest of the type layer — so the only way to obtain a `UserId` is an `as` assertion, which you confine to one factory function. Assignment in the other direction still works: a `UserId` is a subtype of `string`, so any API taking `string` accepts it.

go deeper

for a junior

Know that a type alias is only a name: two aliases for string are the same type and are freely interchangeable. Be able to say that TypeScript compares types by their shape.

for a middle

Explain the intersection trick concretely — what the marker property is, why the branded type still has all string methods, and why two brands with different literal payloads are mutually unassignable.

for a senior

Show judgment about where the assertion that mints a branded value is allowed to live, and argue which domain values are worth branding versus where the ceremony is not repaid.

for a principal

Own the policy question: which primitive confusions in the system justify a brand, whether brands are generated from one shared helper, and how the pattern is documented so error messages mentioning a phantom property do not baffle the next team.

## The starting point: aliases are not new types A type alias in TypeScript introduces a **name** for a type; it does not introduce a new type. After the compiler resolves `type UserId = string` and `type OrderId = string`, both names refer to the same thing — `string`. There is no declaration identity attached to an alias the way there is in a nominal language, where `newtype UserId = String` or a Java wrapper class would produce something genuinely distinct. ```ts type UserId = string; type OrderId = string; declare function loadUser(id: UserId): void; declare const orderId: OrderId; loadUser(orderId); // compiles: both are just string ``` ## Why structural comparison makes this unavoidable TypeScript decides whether type A is assignable to type B by comparing what the types *contain*, not where they were declared. Two types with the same members are interchangeable. That is the right default for describing JavaScript values — it is what lets you pass any object with the right fields to a function — but it means a domain distinction that lives only in a name is invisible to the checker. Ids, currency amounts, raw versus sanitized HTML, validated versus unvalidated email: all of them are `string` or `number` to the compiler. ## The brand: give the type a shape nothing else has The standard remedy is to intersect the underlying type with an object type carrying a marker property: ```ts type UserId = string & { readonly __brand: 'UserId' }; type OrderId = string & { readonly __brand: 'OrderId' }; ``` Three things follow. 1. **The value still behaves like a string.** Intersection means "has everything from both sides", so `UserId` has every `string` member. `id.toUpperCase()`, `id.length`, `` `${id}` `` and `Record<UserId, User>` all work, because `UserId` is a subtype of `string`. 2. **A plain string is no longer assignable to it.** `const id: UserId = 'u_1'` is an error: `string` has no `__brand` member. 3. **The two brands do not match each other.** `UserId` and `OrderId` differ in the *type* of `__brand` — the literal type `'UserId'` versus `'OrderId'` — so neither is assignable to the other. This is why the marker carries a distinct literal payload rather than, say, `boolean`; two brands with an identical key *and* identical payload collapse back into the same type and swap freely again. ## Producing a branded value Since no ordinary string satisfies the brand, values are minted with an assertion: ```ts function toUserId(raw: string): UserId { return raw as UserId; } ``` The assertion is legal because the branded type is a subtype of `string`, and `as` is permitted between types related in either direction. The discipline is to allow that assertion in exactly one place — a factory, ideally in the module that owns the type — so every branded value in the program came through code you can read. Nothing in the compiler enforces that discipline; the brand is a promise the checker takes at face value. ## Direction and cost Branding is one-directional by design. `UserId` flows freely into anything typed `string`, so logging, string concatenation, and third-party APIs keep working; only the reverse — an unbranded string arriving where a `UserId` is required — is blocked. That asymmetry is what makes the pattern cheap to adopt incrementally: you brand the *producers* and the *consumers* you care about, and the rest of the codebase is unaffected. At runtime the pattern costs nothing at all. Types are erased, `as` emits no code, and the branded value is the original primitive — no allocation, no wrapper object, no property lookup. That is the main reason brands are preferred over the obvious alternative of a wrapper class or `{ value: string }` object, which would cost an allocation per id and force unwrapping at every use site. ## When it is worth it Brands earn their keep when two values of the same primitive type are genuinely confusable and a swap would be silent and expensive: entity ids across tables, currency minor units versus major units, milliseconds versus seconds, sanitized versus raw text. They are not worth it for a value that only ever appears in one function. The costs are real if modest: an extra factory per branded type, an assertion sitting somewhere in your code, and error messages that mention `__brand` and confuse readers who have not seen the pattern before.

  • Once a value is branded, can you still pass it to a function that takes a plain string?
    Yes. The branded type is an intersection containing `string`, so it is a subtype of `string` and flows into any `string` parameter — logging, concatenation and third-party APIs keep working unchanged. Only the reverse direction is blocked: a bare `string` is not assignable to the branded type. That asymmetry is deliberate, and it is what makes brands adoptable incrementally rather than as a whole-codebase rewrite.
  • What happens if two domain types are branded with the same property name and the same literal payload?
    They become the identical type again, and values swap freely — the protection quietly disappears. The distinguishing part is the *type* of the marker property, not the fact that a marker exists. Each brand needs its own literal payload (or its own key), which is why the pattern is usually generated from a helper such as `type Brand<T, B extends string> = T & { readonly __brand: B }` rather than written out by hand each time.
  • Why prefer a phantom brand over a wrapper class or a `{ value: string }` object?
    A brand is erased entirely: the value stays the original primitive, so there is no allocation, no unwrapping at use sites, and all `string` methods remain available. A wrapper allocates an object per id and forces `.value` at every boundary — including JSON serialization, map keys and logging. Wrappers do buy real runtime encapsulation, so they are the right call when you also need to attach behaviour or hide the raw value at runtime.

A type alias is a nickname on an envelope; the postal system still sorts by the address inside. A brand staples a slip to the envelope that only your mailroom can print.

saying these in an interview costs you the question

  • Thinks a type alias creates a new distinct type
  • Claims TypeScript compares types by name, like Java
  • Expects const id: UserId = 'u_1' to compile after branding
  • Thinks the brand property must exist on the value at runtime
  • Believes branding also blocks passing a UserId to a string parameter

context

open as a page

In TypeScript, given `type UserId = string & { readonly __brand: 'UserId' }`, what JavaScript does `const id = 'u_1' as UserId` compile to, and what does reading `id.__brand` give you at runtime?

level: juniorimportance: should knowfreq 40%

basics

~20 s

It compiles to const id = 'u_1';. The type and the assertion are erased, so the value is an ordinary string primitive and id.__brand is undefined at runtime — the brand exists only during type checking.

open as a page

In TypeScript, a branded type such as `type Email = string & { readonly __brand: 'Email' }` can only be produced with an `as` assertion. How do you structure code so that assertion is trustworthy, and what does the brand still not guarantee?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Confine the assertion to one exported factory that checks the value first and returns the branded type or a failure, and export the type without any other way to mint it. The brand still guarantees nothing at runtime: it records that a value passed through that factory, nothing about the value itself.

open as a page

In TypeScript, why is an instance of `class Meters { constructor(private value: number) {} }` not assignable to `class Feet { constructor(private value: number) {} }`, even though the two classes have identical shapes?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

Private and protected members are matched by declaration, not by name and type. Each class declares its own value, so the two never match and the classes compare nominally instead of structurally — the one place TypeScript is nominal by default.

open as a page

In TypeScript, what do you gain by branding with a module-private `declare const brand: unique symbol` used as the key, compared with a plain property key such as `__brand: 'UserId'`?

level: seniorimportance: nice to knowfreq 25%

basics

~20 s

A unique symbol key cannot be written by code that does not have the symbol, so brands cannot be forged or matched by accident, and it cannot collide with a real data property. Give each brand its own symbol and several brands can compose on one value.

open as a page