- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have seen this bug. A function takes userId: string and
organizationId: string. Somewhere five files away, a developer
in a hurry passes them in the wrong order. The compiler accepts
it. The query runs. The wrong rows come back. The unit test
passes because the unit test fixture used the same string for
both.
string is the shape both arguments share. string is also the
shape every other ID in the codebase shares. The type system has
no way to tell UserId from OrgId from OrderId from any
other 26-character UUID, and so it does not.
Branded types fix this. The pattern most TypeScript codebases
reach for is unique symbol. It is the textbook answer. It also
costs you ergonomics in places you do not expect: composition,
generics, mapped types. Most teams do not need that level of
locking-down. There is a lighter alternative that composes better
with the rest of the type system. It costs you one weak guarantee
that is almost always acceptable.
This is that pattern, the trade-off it makes against the
unique symbol version, and the cases where each one is right.
The two patterns side by side
The phantom-string-literal version:
type UserId = string & { readonly __brand: "UserId" };
type OrgId = string & { readonly __brand: "OrgId" };
function asUserId(s: string): UserId {
return s as UserId;
}
function loadUser(id: UserId) { /* ... */ }
const u = asUserId("u_123");
const o = "org_456" as string;
loadUser(u); // ok
loadUser(o); // error: string is not assignable to UserId
The unique symbol version:
declare const UserIdBrand: unique symbol;
declare const OrgIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: true };
type OrgId = string & { readonly [OrgIdBrand]: true };
function asUserId(s: string): UserId {
return s as UserId;
}
Both block the original bug. Pass an OrgId where a UserId is
expected and the compiler rejects it. Pass a raw string and the
compiler rejects it. At runtime both are still strings — the
brand is a phantom field that the JavaScript engine never sees.
The difference shows up the moment you try to do anything more
than a single brand on a single type.
Why the brand is in the type, not on the value
Read the type again. string & { readonly __brand: "UserId" }.
That intersection has no runtime expression. The object literal
on the right is not constructed; the field is not assigned; the
JSON of a UserId is the same JSON as a plain string. The brand
is a fiction the type-checker tracks and then erases.
This matters for two reasons.
First, the cost is zero. No wrapper class, no allocation per ID,
no id.value indirection. A UserId is bit-for-bit a string
at runtime, so any code that already accepts strings keeps working
without adapters: JSON.stringify, fetch, query parameters,
log lines.
Second, the brand is opaque from outside the module. If you
declare UserId and only export the type plus the asUserId
constructor, no caller can build a UserId without going
through your validator. They cannot reach in and set the
__brand field, because there is no field to reach for. There
is only the constructor's as UserId cast, which is the gate.
The unique symbol version makes a slightly stronger version
of that second guarantee, which is the reason it gets pulled in.
What unique symbol actually buys you
A unique symbol is a symbol whose type is itself. Two
declarations of unique symbol are guaranteed-distinct by
the compiler — even if they share the same string description,
even if they live in the same scope. The compiler refuses to
unify them.
That gets you collision-resistance for free. Two libraries can
both declare type UserId = string & { readonly [Brand]: true }
with their own unique symbol brand, and TypeScript treats the
two UserId types as completely incompatible. The string-literal
version cannot promise that. If both libraries pick the literal
"UserId", the structural equality check passes, and one
library's UserId flows into the other library's API without
a complaint.
That collision case is real. It is also rare. You hit it when
two third-party packages ship branded types with the same brand
literal, or when you copy-paste a brand declaration across
codebases that later get composed. In a single application
codebase, where you control every brand declaration, the
collision space is the size of your own discipline.
So the trade is: unique symbol gives you collision-resistance
across module boundaries; phantom-string-literal gives you the
same call-site safety inside your codebase, at the cost of
trusting that you do not duplicate a brand string by accident.
Where the lighter version composes better
The interesting part is what you can build on top of the
phantom-string-literal version that is awkward or impossible
on the unique symbol version.
Mapped types over branded fields:
type Brand<K extends string, T = string> =
T & { readonly __brand: K };
type UserId = Brand<"UserId">;
type OrgId = Brand<"OrgId">;
type OrderId = Brand<"OrderId", number>;
type BrandOf<T> =
T extends Brand<infer K, infer _> ? K : never;
type ValueOf<T> =
T extends Brand<infer _, infer V> ? V : never;
BrandOf<UserId> returns the literal "UserId". ValueOf<OrderId>
returns number. You can write generic helpers that switch on
the brand, build runtime tables keyed by it, generate validators
from a registry of brands. The brand is data the type system can
read, because it is a string literal. That is the same kind of
literal template-literal types and conditional types know how to
manipulate.
The unique symbol version cannot do any of this. The brand is
a unique symbol, which means it is its own type. There is no
literal to read, no string to compare, no value to switch on.
You can use it as a key. You cannot interrogate it. The moment
you try to build generic machinery over your branded types, the
symbol brand goes opaque on you. A serializer, a validator
registry, a fixture builder, a route parameter parser that knows
which IDs go where: none of them can read the brand.
Two more places the lighter version pays off:
A factory generator:
function brand<K extends string>() {
return <T extends string = string>(value: T) =>
value as Brand<K, T>;
}
const userId = brand<"UserId">();
const orgId = brand<"OrgId">();
const u = userId("u_123"); // Brand<"UserId", "u_123">
u is not just a UserId — it is Brand<"UserId", "u_123">,
narrowed to the exact literal. That is useful for routes,
feature flags, enum-like IDs where the value space is small.
With unique symbol you cannot factor the factory generator
this way; the brand is a symbol literal that has to be declared
at module scope, not generated.
A discriminated union of brands:
type AnyId = UserId | OrgId | OrderId;
function describe(id: AnyId): string {
// Cannot switch on `id` itself — the brand is a phantom.
// But you can pattern-match in a registry keyed by literal.
return id.toString();
}
type IdRegistry = {
UserId: (id: UserId) => Promise<User>;
OrgId: (id: OrgId) => Promise<Org>;
OrderId: (id: OrderId) => Promise<Order>;
};
The keys of IdRegistry are the same literals that drive the
brand. You can derive one from the other. With unique symbol,
the keys would have to be the symbols themselves, which means
your registry type is a tuple of symbol-keyed entries — readable
in the type-checker, painful for any tooling outside it
(autocomplete, JSON schemas, generated docs).
When the trade-off favors unique symbol
The string-literal pattern loses in three cases.
If you are publishing a library and your branded types are part
of the public surface. A consumer might re-declare the same
brand string by accident or on purpose, and their type would
silently merge with yours. That is the case unique symbol was
designed for. Use it for public API surfaces.
If your branded type wraps a non-string value and the brand
collision space is dense. Branded bigints and branded numbers
that all use a small set of brand strings ("id", "key",
"hash") have a higher collision probability than branded
strings. There are fewer natural-sounding brand literals when
the wrapped type is not domain-flavored.
If you have a security-sensitive boundary where you need
provenance proofs. A Validated<T> brand that says "this string
went through Zod" is a property you do not want a downstream
caller to forge by hand-rolling the literal. unique symbol is
the right tool. Even there, the practical attack surface is
small. A developer who can write value as Validated<T> can
bypass any brand anyway. If you want the friction, use the
symbol.
For everything else, the phantom-string brand is enough:
internal IDs, feature flags, domain entity keys, the
bread-and-butter UserId/OrgId distinction that catches the
original argument-swap bug. Two lines per brand, no
declare const preamble, no symbol indirection, full
composability with the rest of the type system.
What to do with this on Monday
Pick three string fields in your codebase that should have been
distinct types from day one. The candidates are easy to spot:
function signatures with two or more string parameters that a
fresh reader cannot order without looking at the JSDoc.
Write the brands:
type Brand<K extends string, T = string> =
T & { readonly __brand: K };
export type UserId = Brand<"UserId">;
export type OrgId = Brand<"OrgId">;
export type EmailAddress = Brand<"EmailAddress">;
export const asUserId = (s: string): UserId =>
s as UserId;
export const asEmail = (s: string): EmailAddress => {
if (!s.includes("@")) throw new Error("not an email");
return s as EmailAddress;
};
Change one function signature to take the brand instead of
string. Compile. Watch the type errors fan out across the
caller graph — those are exactly the places that were quietly
passing the wrong value. Walk each one back to its origin and
either route it through the constructor or fix the actual bug.
You do not need a refactor sprint. Branded types are a
local-by-local change: each new boundary you brand catches the
bugs that crossed that boundary, and the rest of the codebase
keeps compiling untouched.
Brand the next boundary you touch and let the type errors do
the rest of the work for you.
If this was useful
The TypeScript Type System
in The TypeScript Library is the deep type-system home for
patterns like this — branded types, mapped types, conditional
types, infer, template-literal types, and the higher-kinded
shapes you build on top of them. The branded-types chapter
covers the trade-off matrix above plus the variants you reach
for once you have shipped this in a real codebase. It is one of
five books in the collection:
- TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
-
The TypeScript Type System — the deep dive. Generics, mapped and conditional types,
infer, template literals, branded types. - Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
- PHP to TypeScript — bridge for PHP 8+ developers. Sync to async, generics, discriminated unions.
- TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
Books 1 and 2 are the core path. Book 2 is the natural home for
this post — the same chapter that covers branded types covers
the mapped-type and conditional-type machinery the lighter
pattern composes with. Book 5 is for anyone shipping TypeScript
at work.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)