Domain-Driven Types
If a hedgehog can fly, your types are wrong.
The type system is the cheapest documentation a codebase will ever carry. Encode the domain in types so the invalid states cannot compile, and half the comments retire themselves before the next reader arrives.
Opinion
AI eyes only
Rule: types encode the rules of the domain. Make invalid states unrepresentable.
Reject: string for ids when a brand exists. Reject: optional fields for states that should be discriminated unions. Reject: any to “get past the compiler”.
Generate: branded primitives for ids and value objects. Discriminated unions for states. Validated DTOs at boundaries. Result<T, E> for fallible operations.
Diagnostic: try to construct an invalid instance using only the type. If you can, the type is too loose; tighten before continuing.
Why?
- Invalid states stop compiling. The bug that ships is the bug the type let through; tighten the type and the bug class disappears. 1“Effective ML / Make Illegal States Unrepresentable” (Jane Street, 2011). The canonical name for the discipline; the type system as a positive constraint, not a passive checker.
- Half the explanatory comments retire on the spot. A
Branded<string, “UserId”>carries the constraint a comment was patching — see F5 Self-Documenting Code. - Refactors are mechanical. Rename a field and the compiler tells you every callsite. Without types, the same change is a manual sweep with regex.
- The IDE earns its keep — autocompletion that knows the domain, errors at the keystroke, jump-to-definition that walks the contract.
- Coding agents stop hallucinating shapes when the shape is a type. The model can't invent a field that doesn't exist; the compiler refuses the diff before the human reads it.
Origins
The case for types-as-documentation predates TypeScript by decades. Tony Hoare's 1965 invention of the null reference, the “billion-dollar mistake” he later named, is the negative case: a type that admitted the bad state shipped the bad state every time. Edwin Brady's Type-Driven Development with Idris (2017) is the positive case in book form: write the type first, let the type drive the test, let the test drive the implementation. 2Type-Driven Development with Idris (Manning, 2017). Book-length treatment of type-first design, with worked examples in Idris.
Yaron Minsky's 2010 Jane Street talk, Effective ML, gave the principle the name that stuck: “make illegal states unrepresentable.” 1“Effective ML / Make Illegal States Unrepresentable” (Jane Street, 2011). The canonical name for the discipline; the type system as a positive constraint, not a passive checker. Scott Wlaschin's Designing with Types series translated the discipline from ML into F# and made it accessible to a working developer audience. 3“Designing with Types” series (fsharpforfunandprofit.com, 2013). The accessible translation of the ML discipline into F#, with practical worked examples. Eric Evans's Domain-Driven Design (2003) is the architectural complement: bounded contexts, value objects, ubiquitous language. The model is what types encode in the first place. 4Domain-Driven Design (Addison-Wesley, 2003). The architectural complement; the model that types encode.
Quotes
Make illegal states unrepresentable.
The type system is your friend. It's not just there to catch typos — it's a design tool. A well-designed type system can make whole classes of errors impossible.
Define the type, then define the function. The type is a specification; the function is a proof.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01Coined the canonical phrase. The type system as a constraint-checker, not just a linter.
- 02Designing with Types (series)SupportsTranslated the ML/Haskell discipline into F# with worked examples — single states, sum types, branded primitives, validation by type.
- 03Book-length treatment of types as the primary design surface. Define the type, then define the function — the type is the spec.
- 04Architectural companion: value objects, bounded contexts, ubiquitous language — the domain model that the types encode.
- 05Names and types are the same problem from two angles — both let the code carry meaning the prose was patching.
Eight sources, one direction. The honest counter is that types over-engineer simple code; the reply addresses it directly below.
Examples
// Before: three primitives. Pass a region where the id was meant; nothing flags it.function tagHedgehog(id: string, weight: number, region: string): void { writeTag({ id, weight, region });}// caller — id and region transposed; compiler is silent:tagHedgehog("ashdown-forest", 412, "HH-0042");
// After: domain types. The illegal call cannot compile.type HedgehogId = string & { readonly __brand: "HedgehogId" };type WeightGrams = number & { readonly __brand: "WeightGrams" };type ForestRegion = "ashdown" | "epping" | "sherwood";function tagHedgehog(id: HedgehogId, weight: WeightGrams, region: ForestRegion): void { writeTag({ id, weight, region });}// caller — transposed call now fails to compile:tagHedgehog("ashdown", 412, "HH-0042"); // type error: string is not HedgehogId
Enforcement
Apply these rules in tsconfig.json. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| strict | tsc | the umbrella flag — turns on noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, alwaysStrict and useUnknownInCatchVariables. |
| noUncheckedIndexedAccess | tsc | array[i] returning T instead of T | undefined — the single biggest hole in 'strict' that ships bugs. |
| exactOptionalPropertyTypes | tsc | the silent equivalence between { x?: number } and { x?: number | undefined }. |
| @typescript-eslint/no-explicit-any | typescript-eslint | any type written by hand — the only legal escape is unknown plus a type guard. |
| @typescript-eslint/explicit-function-return-type | typescript-eslint | missing return-type annotations on exported functions. |
| @typescript-eslint/no-unsafe-assignment | typescript-eslint | any leaking into typed code through untyped JSON, library boundaries. |
tsconfig.jsonconfiguration snippet
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true
}
}AI rules
.cursor/rules/t1-domain-driven-types.mdc---
description: Prickles T1 — Domain-Driven Types
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles T1 — Domain-Driven Types
> Canon: https://prickles.org/tenet/domain-driven-types/T1
Brand the primitives. A user ID is not a string; an email is not a string; a UUID is not a string. Wrap each domain primitive in a typed brand the compiler enforces, so passing a BookingId where a UserId is expected fails at compile time.
If two values of the same primitive type can never be assigned to each other in the domain, give them different types. Internal IDs and external Stripe IDs are not interchangeable; cents and pounds are not the same number; ISO-8601 strings and unix timestamps are not the same date.
Model invalid states out of existence. A discriminated union with one tag per legal state beats a flag-soup of optional booleans. If a hedgehog can be both isHibernating: true and isFeeding: true, the type is wrong.
Validators sit at boundaries, not throughout the codebase. Parse once at the edge — JSON.parse, route input, external API response — then trust the type for the rest of the call. Parse, do not validate.
When a function takes more than two parameters of the same primitive type, introduce domain types or a parameter object. transfer(from: string, to: string, amount: number) is a bug waiting to happen; transfer(from: AccountId, to: AccountId, amount: Money) removes the entire class.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
The honest steelman is over-engineering. A small script does not need a Result<T, E> for a one-line transformation; the friction of writing the type costs more than the bug it prevents at that scale. Dynamic-language advocates will go further: types ossify a design before the design is known, and the cost of changing a pervasive type is steeper than the cost of changing a pervasive call. Sandi Metz's The Wrong Abstraction applies here too: the wrong type is harder to remove than the wrong function.
Counter-argument retort
Granted in scope, not in principle. For a 30-line script, skip the brand. For a system that ships, type the signatures and the comments retire themselves. The cost of changing a pervasive type is large only because the type is doing real work; that work was the documentation that nobody wrote because they trusted the comment instead. Pay the friction at the signature, save the debug at three in the morning. 5ValueObject (martinfowler.com/bliki, 2016). Pattern reference for the value-object idea that branded primitives implement at the type level.
Notes
- [1]Yaron Minsky — “Effective ML / Make Illegal States Unrepresentable” (Jane Street, 2011). The canonical name for the discipline; the type system as a positive constraint, not a passive checker.
- [2]Edwin Brady — Type-Driven Development with Idris (Manning, 2017). Book-length treatment of type-first design, with worked examples in Idris.
- [3]Scott Wlaschin — “Designing with Types” series (fsharpforfunandprofit.com, 2013). The accessible translation of the ML discipline into F#, with practical worked examples.