skip to content

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%

answer

  1. who is allowed to spell the key
  2. accidental structural match, not just malice
  3. two brands, one key, one marker slot
  4. the marker type becomes never
  5. declaration emit must name the key

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.

solid answer

~50 s

Two things. First, unforgeability: a `unique symbol` type is tied to one declaration, so code that cannot see `brand` cannot spell the key at all — it can neither write a matching object type by hand nor collide with it accidentally, whereas any file can type `{ __brand: 'UserId' }`. Second, composition and collision safety: `__brand` is an ordinary property name that a real payload might already use, and two different brands sharing that one key produce a marker whose type is `'A' & 'B'`, which is `never` — so nothing can carry both brands at once. Distinct symbol keys sidestep both problems. The costs are ceremony and declaration emit: the symbol must be declared somewhere the compiler can name when it writes the `.d.ts` for a publicly exported branded type. Practically, most codebases start with the string key and move to symbols when brands begin to stack or leak.

code

typescript · 12 lines
typescript
// brand.ts — the symbol stays module-private
declare const brand: unique symbol;

export type Brand<T, B extends string> = T & { readonly [brand]: B };

export type UserId = Brand<string, 'UserId'>;
export type Trimmed = Brand<string, 'Trimmed'>;

// one value can carry several brands only if the keys differ
declare const both: UserId & Trimmed;
const asPlain: string = both;
console.log(asPlain.length);

go deeper

for a junior

Know that the brand key is just a property key in the type layer, and that the plain __brand form is the one you will meet most often in real code.

for a middle

Explain what unique symbol means — an identity tied to one const declaration — and why the marker must therefore be introduced as a const and used as a computed key.

for a senior

Show the composition failure concretely: two brands on one key give a marker typed never, so no value satisfies both, and argue when that forces the move to symbol keys.

for a principal

Own the library-surface decision: whether branded types are exported at all, how declaration emit names the key, and what the team pays in diagnostics readability for unforgeability.

## The two branding keys The common form uses an ordinary property name: ```ts type UserId = string & { readonly __brand: 'UserId' }; ``` The stricter form uses a symbol as the key, declared once and kept module-private: ```ts declare const brand: unique symbol; type Brand<T, B extends string> = T & { readonly [brand]: B }; type UserId = Brand<string, 'UserId'>; type Email = Brand<string, 'Email'>; ``` `declare const brand: unique symbol` is an ambient declaration: it introduces a type-level identity and emits no runtime code. `unique symbol` is only permitted on a `const` declaration or a `static readonly` member — you cannot write it inline inside a type literal — which is exactly why the pattern declares the const first and uses `[brand]` as a computed key. ## Gain 1: the key cannot be spelled elsewhere A `unique symbol` type is bound to that one declaration. Another module cannot produce a type that structurally matches `{ readonly [brand]: 'UserId' }` without importing the symbol, because there is no way to write the key. With `__brand`, any file anywhere can declare `type Forged = string & { readonly __brand: 'UserId' }` and produce values that the checker accepts wherever a real `UserId` is required — usually by accident rather than malice, but the effect is the same. Keeping the symbol unexported turns the branding module into the only place brands can be *named*, which is a stronger form of the discipline the pattern relies on. Be precise about the limit: this does not stop `as`. Any code holding a string can still assert it to `UserId` if the *type* is exported. The symbol removes the accidental-structural-match route, not the assertion route. ## Gain 2: no collision with real data `__brand` is a valid property name. If a branded object type is later intersected with a payload that genuinely has a `__brand` field — a common enough name in serialization formats — the two meanings interfere. A symbol key can never collide with a string key, so the brand and the data occupy disjoint namespaces. ## Gain 3: brands compose This is the practical driver. Consider two brands sharing one key: ```ts type UserId = string & { readonly __brand: 'UserId' }; type Trimmed = string & { readonly __brand: 'Trimmed' }; type Both = UserId & Trimmed; // marker type is 'UserId' & 'Trimmed' = never ``` The marker property's type becomes the intersection of two disjoint string literals, which is `never`, so no value can inhabit `Both`. That is fine while brands are mutually exclusive labels, and fatal as soon as you want "a validated *and* trimmed string" or "a UserId that is also a database key". Give each brand its own symbol key and the intersection has two independent properties, so a single value can carry both. A middle path exists with string keys: use a distinct key per brand, `{ readonly __userId: 'UserId' }` and `{ readonly __trimmed: 'Trimmed' }`. That composes fine and needs no symbol; it just leaves the keys spellable. ## Costs **Ceremony.** A branding module, a helper alias, and a factory per brand — more moving parts than one intersection written inline. **Declaration emit.** If a branded type is part of a package's public surface and you emit `.d.ts` files, the compiler has to be able to *name* the brand key in the generated declaration. Keep the symbol declaration in the same module that exports the branded types (and export it if the generated declaration needs to refer to it from elsewhere), or declaration emit will not be able to describe the type. **Error messages.** Diagnostics mentioning a symbol-keyed property are harder to read than ones mentioning `__brand`, and readers unfamiliar with the pattern will need a comment pointing at it. ## Choosing For an application codebase with a handful of id types that never stack, the string key is honest and readable. Move to symbols when brands begin to compose, when branded types are exported from a library where a third party could recreate the shape by hand, or when the marker key risks colliding with real payload fields. Both forms are equally free at runtime — the key, the marker and the alias are all erased.

  • Why can you not write `type UserId = string & { readonly __brand: unique symbol }` directly?
    `unique symbol` is only allowed as the type of a `const` variable declaration or a `static readonly` member — the compiler rejects it in an arbitrary type position, because the type's identity has to be anchored to a specific declaration. That is why the pattern declares `declare const brand: unique symbol` first and then uses `[brand]` as a computed key. The identity lives on the const; the type merely refers to it.
  • Does a symbol-keyed brand stop someone asserting an arbitrary string into the branded type?
    No. If the branded type alias is exported, any holder of a string can still write `raw as UserId`; the assertion route is unaffected. What the symbol removes is the *structural* route — recreating a matching type by hand, or an unrelated object accidentally satisfying the shape. Blocking assertions is a lint-rule and code-review concern, not something the type system can do.
  • Is there a cheaper way to let two brands coexist on one value without symbols?
    Yes — give each brand its own string key rather than sharing `__brand`: `{ readonly __userId: 'UserId' }` and `{ readonly __trimmed: 'Trimmed' }`. The intersection then has two independent properties instead of one slot with conflicting literal types, so a value can carry both. You keep readable error messages and give up only unforgeability of the key.

saying these in an interview costs you the question

  • Writes unique symbol inline inside a type literal
  • Thinks a symbol key blocks `as` assertions too
  • Claims the symbol is compared at runtime
  • Assumes two brands sharing __brand can stack on one value
  • Believes the symbol adds runtime cost or emits a property

context