structural typing's blind spot
typescript is a structural type system. two types are compatible
if they have the same shape. that's mostly a feature — it means you can
pass an object that happens to fit, without inheritance or explicit
declarations. but it has one ugly consequence: if you declare a hundred
different id types as string, the compiler treats them as
one big interchangeable pile.
type UserId = string;
type OrderId = string;
function refund(orderId: OrderId) { /* … */ }
const u: UserId = "u_123";
refund(u); // compiles fine. ships a refund to the wrong row.
nothing in the type system distinguishes UserId from
OrderId — they're both string, so they
structurally match. the alias is a comment that the compiler ignores.
you can write code that the compiler swears is correct and the
payments page disagrees with at 2 a.m.
the brand
a branded type adds a phantom field — a property that exists only in the types, never at runtime — that gives each id its own identity. two strings can have the same shape but different brands, and the compiler stops treating them as interchangeable.
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function refund(orderId: OrderId) { /* … */ }
const u = "u_123" as UserId;
refund(u);
// ^^^^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
// Type '"UserId"' is not assignable to type '"OrderId"'. string & { __brand: "UserId" } is an intersection: a value
that's both a string and an object with that field. no actual string
has the field, so the only way to get one is to assert it
(as UserId) — and the convention is to do that in exactly
one place: the constructor.
Object.keys, doesn't cost a byte. it
exists purely so the structural matcher has something to disagree on.
the constructor pattern
instead of scattering as UserId assertions through the
codebase, fence them into a single function. the function validates
the input (so the brand isn't a lie), then asserts. callers never
cast — they call the function.
type UserId = string & { readonly __brand: "UserId" };
function UserId(raw: string): UserId {
if (!/^u_[a-z0-9]{6,}$/.test(raw)) {
throw new Error(`invalid UserId: ${raw}`);
}
return raw as UserId;
}
// usage
const u = UserId("u_123abc"); // OK
const u2 = UserId("oops"); // throws
const u3: UserId = "u_123abc"; // type error — no cast generic brand helper
you write the brand pattern often enough that it's worth extracting. one helper, parametrized by the brand string, removes the boilerplate of typing out the intersection every time.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
type DKK = Brand<number, "DKK">; // currency. yes, brand numbers too.
type Cents = Brand<number, "Cents">;
function chargeDKK(amount: DKK) { /* … */ }
const cents = 100 as Cents;
chargeDKK(cents);
// ^^^^^
// Argument of type 'Cents' is not assignable to parameter of type 'DKK'. currency is the canonical example. mixing minor and major units (cents vs. dkk, satoshi vs. btc, paise vs. inr) is one of the all-time leading causes of money bugs. brands make it a compile error.
check yourself
branded types — 4 questions
read the snippet, pick what the compiler does.
type UserId = string & { readonly __brand: "UserId" };
const a: UserId = "u_123";
// ^?what happens at the declaration of `a`?
type Email = string & { readonly __brand: "Email" };
type Slug = string & { readonly __brand: "Slug" };
function send(to: Email) { /* … */ }
const s = "team" as Slug;
send(s);does `send(s)` compile?
type UserId = string & { readonly __brand: "UserId" };
const u = "u_123" as UserId;
console.log(typeof u);
console.log(Object.keys(u));
console.log(JSON.stringify({ u }));what gets logged?
type Brand<T, B extends string> = T & { readonly __brand: B };
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
function sleep(ms: Milliseconds) { /* … */ }
const s = 5 as Seconds;
sleep(s);what does typescript say?
three takes on the same id
how strong is your id?
three ways to type a user id, from weakest to strongest. click through to compare.
type UserId = string;
function find(id: UserId) { /* … */ }
const order = "o_999";
find(order); // OK ¯\_(ツ)_/¯// UserId is just string.
// any string fits. structurally
// indistinguishable from OrderId,
// Email, anything else stringy.the alias is a comment. structural matching makes it free to misuse.
type UserId = string & { readonly __brand: "UserId" };
function find(id: UserId) { /* … */ }
const order = "o_999";
find(order);
// ^^^^^
// Argument of type 'string' is not
// assignable to parameter of type 'UserId'.// only values explicitly asserted
// (or returned from a constructor)
// satisfy UserId.
//
// raw strings, other branded ids,
// and unbranded values are rejected.compile-time safety, zero runtime cost. the brand field never materializes.
class UserId {
constructor(public readonly value: string) {}
}
function find(id: UserId) { /* … */ }
find(new UserId("u_123")); // OK
find("u_123"); // type error
find({ value: "u_123" }); // OK — structural!// runtime: real object allocation
// per id. serialization breaks
// (JSON.stringify yields {"value":…}).
//
// structural matching means a plain
// { value: string } satisfies it.real runtime cost, more friction at boundaries (JSON), and not even nominally safe in TS. brands win on every axis.
where the brand lives
keep brands at the system boundary: the layer where untrusted strings turn into typed ids. parse the request body, run the validator, brand the result, then hand the typed value to the rest of the code. internal functions take the branded type and trust it. if every internal function re-validates, you've just spread the cost without buying any safety.
// at the http boundary
function parseUserId(input: unknown): UserId {
if (typeof input !== "string") throw new Error("not a string");
if (!/^u_[a-z0-9]{6,}$/.test(input)) throw new Error("bad shape");
return input as UserId;
}
// every layer below trusts the brand
function userById(id: UserId) { /* no re-validation */ }
function deleteUser(id: UserId) { /* no re-validation */ } what you took away
- typescript's structural matching means same-shape types are interchangeable. for ids and units, that's a bug surface.
- a brand is an intersection with a phantom field. it costs zero bytes at runtime and adds a compile-time wall.
- pair the brand with a constructor function so the cast lives in one place — the boundary where untrusted data becomes trusted.
- brand numbers too. currency units, time units, anything with a magnitude that depends on context.
- don't reach for runtime-wrapper classes. you pay allocation cost and structural matching still bites — brands beat them on every axis.
further reading
- /posts/ · typescript types as documentation — same philosophy, applied to function signatures.
- typescript playground · branded types — play with intersection brands in the official playground.