scalable/craft/ts types in anger/lesson 02 cph / /
lesson 02 / 06 · 10 min · updated ·

branded types

make primitives unforgeable at compile time, with zero runtime cost. UserId is not OrderId — and TS will believe you.

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.

the problem
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.

branded ids
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.

tip the brand field is never read at runtime. it doesn't get serialized, doesn't show up in 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.

branded constructor
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.

reusable brand
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.

assignment without a brand
type UserId = string & { readonly __brand: "UserId" };

const a: UserId = "u_123";
//    ^?

what happens at the declaration of `a`?

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 ¯\_(ツ)_/¯
tsc sees
// 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.

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.

boundary in, brands out
// 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 */ }
rule brand once, at the edge. trust the brand everywhere downstream. the point is to stop the validation from leaking into every function; don't undo that by validating twice.

what you took away

further reading

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk