typescript types as documentation
a comment that says // must be in cents, not dollars is a comment. the next person to touch the function will pass dollars anyway, because they didn’t read the comment. they read the signature.
the pattern: branded types
type Cents = number & { readonly __brand: "Cents" };
function charge(amount: Cents) { /* ... */ }
now the signature carries the constraint. a raw number won’t compile. you need a constructor:
const cents = (n: number): Cents => n as Cents;
charge(cents(1999));
that’s the whole pattern. it’s not new — flow had it, fp-ts has it, scala has phantom types, and scott wlaschin’s designing with types is the canonical write-up of the same shape in f#. the move that matters is recognizing that branded types are documentation that compiles.
where to use them
- units (cents, milliseconds, basis points, square meters)
- ids that share a primitive type (
UserId,OrderId, both strings, never interchangeable) - validated strings (a
TrimmedNonEmptyStringyou can’t pass a raw input to) - positive integers, sorted arrays, normalized urls — anywhere a precondition lives
the cost: every branded type needs a constructor (or a guard) at the boundary. that’s where you do the validation. inside the system, you don’t repeat it.
the cost i didn’t expect: junior engineers find the syntax noisy. fair. so i name the brand the same as the type — type Cents = number & { readonly __brand: "Cents" } — and live with the boilerplate. it’s worth it.
a comment that says “must be in cents” is a wish. a Cents type is a contract. one of those rots. the other doesn’t.