Typing
Position
Scalar types (string, number, boolean) describe the shape of data, not its domain meaning. A structural type system cannot distinguish a ShopId from a CustomerId if both are string — and every real codebase has this bug somewhere. The fix is branded domain types: wrap every meaningful primitive at definition time, validate it once at the system boundary, and let the compiler enforce correctness everywhere else. The cost is one to four lines per type. The alternative is trusting every developer in every PR to get it right.
Rules
- DO: define a branded type for every domain-meaningful primitive — IDs, money amounts, units, sanitised strings. everything should be typed
- DO: use
Brand<T, B extends string> = T & { readonly [__brand]: B }withunique symbolas the canonical helper. everything should be typed - DO: brand individual values, not object containers — a branded wrapper around
OrderPayoutParamsstill lets you fill its fields with semantically wrong values. everything should be typed - DO: validate and brand at the system boundary (API response, form input, URL param, WebSocket message) exactly once. everything should be typed
- DO: trust branded types everywhere inside the boundary — no function should re-validate what the constructor already guaranteed. everything should be typed
- DO: use Zod
.brand()as the default FE validation strategy — one schema delivers runtime validation + compile-time branding in a singleparse()call. everything should be typed - DO: use a
RawUserInput→SanitizedHtmlconversion type to enforce injection safety at the compiler level. everything should be typed - DON’T: use
stringornumberdirectly as function parameters when the argument has a specific domain role. everything should be typed - DON’T: brand the container object and consider the job done — the brand must live on each value. everything should be typed
- DON’T: re-validate branded values inside service/repository layers — that is the boundary’s job. everything should be typed
Open questions
- At what granularity should branding stop? (e.g. should
EmailLocalPartandEmailDomainbe separate types fromEmail?) - How does Zod
.brand()interact with tRPC and server-side type sharing across a full-stack TypeScript monorepo?
Conflicts
(none)