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 reaches production is the bug the type let through. Tighten the type and the bug class disappears. 1
- Half the explanatory comments retire on the spot. A
Branded<string, “UserId”>holds 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 justifies itself, 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 rejects 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. 2
Yaron Minsky's 2010 Jane Street talk, Effective ML, gave the principle the name that stuck: “make illegal states unrepresentable.” 1 Scott Wlaschin's Designing with Types series translated the discipline from ML into F# and made it accessible to a working developer audience. 3 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. 4
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
8 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.
- 06TypeScript's design rationale from its creator: gradual typing as a path from JavaScript to a type-disciplined codebase.
- 07TypeScript Handbook · NarrowingSupportsReference for discriminated unions and type guards: the load-bearing technique for replacing runtime checks with compile-time proofs.
- 08ValueObject (bliki)SupportsDefines the value-object pattern that branded primitives implement at the type level.
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 }); }
rescue-intake.tsid and region transposed; the compiler is silenttagHedgehog("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 }); }
rescue-intake.tsthe transposed call fails to compile: a string is not a HedgehogIdtagHedgehog("ashdown", 412, "HH-0042");
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 }); }
rescue-intake.tsid and region transposed; the compiler is silenttagHedgehog("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 }); }
rescue-intake.tsthe transposed call fails to compile: a string is not a HedgehogIdtagHedgehog("ashdown", 412, "HH-0042");
Before three primitives. Pass a region where the id was meant; nothing flags it.
function tagHedgehog(id, weight, region) { writeTag({ id, weight, region }); }
rescue-intake.jsid and region transposed; nothing complainstagHedgehog("ashdown-forest", 412, "HH-0042");
After factories validate at the boundary; downstream callers cannot mix shapes.
const FOREST_REGIONS = Object.freeze(["ashdown", "epping", "sherwood"]); const hedgehogId = (raw) => { if (!/^HH-\d{4}$/.test(raw)) throw new Error("not a hedgehog id"); return { kind: "HedgehogId", value: raw }; } function tagHedgehog(id, weight, region) { if (id.kind !== "HedgehogId") throw new Error("id must come from hedgehogId()"); if (!FOREST_REGIONS.includes(region)) throw new Error("unknown region"); writeTag({ id: id.value, weight, region }); }
rescue-intake.jsa transposed call now throws at the boundarytagHedgehog(hedgehogId("HH-0042"), 412, "ashdown");
Before three primitives. Pass a region where the id was meant; mypy is silent.
def tag_hedgehog(id: str, weight: float, region: str) -> None: write_tag(id=id, weight=weight, region=region)
rescue_intake.pyid and region transposed; mypy says nothingtag_hedgehog("ashdown-forest", 412, "HH-0042")
After NewType brands and a Literal union; the illegal call fails to type-check.
from typing import Literal, NewType HedgehogId = NewType("HedgehogId", str) WeightGrams = NewType("WeightGrams", float) ForestRegion = Literal["ashdown", "epping", "sherwood"] def tag_hedgehog(id: HedgehogId, weight: WeightGrams, region: ForestRegion) -> None: write_tag(id=id, weight=weight, region=region)
rescue_intake.pya transposed call now fails mypytag_hedgehog(HedgehogId("HH-0042"), WeightGrams(412), "ashdown")
Before three primitives. Pass a region where the id was meant; nothing flags it.
void tagHedgehog(String id, double weight, String region) { writeTag(id, weight, region); }
RescueIntake.javaid and region transposed; javac is silenttagHedgehog("ashdown-forest", 412, "HH-0042");
After records and a sealed region; the illegal call cannot compile.
record HedgehogId(String value) {} record WeightGrams(double value) {} enum ForestRegion { ASHDOWN, EPPING, SHERWOOD } void tagHedgehog(HedgehogId id, WeightGrams weight, ForestRegion region) { writeTag(id, weight, region); }
RescueIntake.javaa transposed call now fails to compiletagHedgehog(new HedgehogId("HH-0042"), new WeightGrams(412), ForestRegion.ASHDOWN);
Before three primitives. Pass a region where the id was meant; phpstan stays quiet.
function tagHedgehog(string $id, float $weight, string $region): void { writeTag($id, $weight, $region); }
RescueIntake.phpid and region transposed; nothing complainstagHedgehog("ashdown-forest", 412, "HH-0042");
After a readonly value object and a backed enum; the illegal call fails phpstan.
final readonly class HedgehogId { public function __construct(public string $value) {} } final readonly class WeightGrams { public function __construct(public float $value) {} } enum ForestRegion: string { case Ashdown = "ashdown"; case Epping = "epping"; case Sherwood = "sherwood"; } function tagHedgehog(HedgehogId $id, WeightGrams $weight, ForestRegion $region): void { writeTag($id, $weight, $region); }
RescueIntake.phpa transposed call now fails phpstantagHedgehog(new HedgehogId("HH-0042"), new WeightGrams(412), ForestRegion::Ashdown);
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 lets bugs through. |
| 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
}
}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 lets bugs through. |
| 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
}
}Apply these rules in tsconfig.json + eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| checkJs | tsc | type-checks .js files using JSDoc annotations as the source of truth. |
| jsdoc/check-types | eslint-plugin-jsdoc | JSDoc types that disagree with the implementation. |
| jsdoc/require-param-type | eslint-plugin-jsdoc | @param entries without a type: the JSDoc equivalent of any. |
| jsdoc/require-returns-type | eslint-plugin-jsdoc | @returns without a type. |
| no-implicit-globals | ESLint core | untyped globals leaking into module scope. |
tsconfig.json + eslint.config.mjsconfiguration snippet
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"allowJs": true,
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*.js"]
}Apply these rules in pyproject.toml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| --strict | mypy | the umbrella flag: turns on disallow-untyped-defs, disallow-any-generics, no-implicit-optional and the rest of the strict family. |
| disallow_any_explicit | mypy | Any written by hand: equivalent to TypeScript's no-explicit-any. |
| warn_return_any | mypy | functions returning Any without an explicit cast. |
| ANN101 / ANN201 | Ruff (flake8-annotations) | missing return-type annotations and self-type annotations. |
pyproject.tomlconfiguration snippet
[tool.mypy]
strict = true
disallow_any_explicit = true
disallow_any_generics = true
disallow_untyped_defs = true
warn_return_any = true
no_implicit_reexport = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_decorators = falseApply these rules in pmd-ruleset.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| MissingOverride | PMD | method overrides without @Override: masks renames during refactors. |
| UnnecessaryCast | PMD | explicit casts the type system already proves redundant: usually a sign of a missing generic. |
| UseExplicitTypes | PMD | var declarations whose inferred type is non-obvious to a reader. |
| PreserveStackTrace | PMD | exceptions wrapped without preserving the cause: type information lost at runtime. |
| S5411 (boxed Boolean unboxing) | SonarQube Java | implicit unboxing of Boolean: the silent NPE. |
pmd-ruleset.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="java-types">
<rule ref="category/java/codestyle.xml/UseExplicitTypes"/>
<rule ref="category/java/codestyle.xml/UnnecessaryCast"/>
<rule ref="category/java/bestpractices.xml/PreserveStackTrace"/>
<!-- Sealed-class enforcement requires JDK 17+ -->
<rule ref="category/java/bestpractices.xml/MissingOverride"/>
</ruleset>Apply these rules in psalm.xml / phpstan.neon. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| errorLevel=1 | Psalm | the strictest level: no mixed types, no untyped properties, no missing returns. |
| level: max | PHPStan | PHPStan equivalent of Psalm errorLevel 1. |
| declare(strict_types=1) | PHP runtime | implicit type coercion at function boundaries: strict_types makes the engine throw rather than coerce. |
| MissingParamType / MissingReturnType | Psalm | function signatures without param or return types. |
| MixedAssignment | Psalm | mixed types leaking through: the equivalent of any. |
psalm.xml / phpstan.neonconfiguration snippet
<?xml version="1.0"?>
<!-- psalm.xml -->
<psalm errorLevel="1" findUnusedCode="true" findUnusedVariablesAndParams="true">
<projectFiles>
<directory name="src"/>
</projectFiles>
</psalm>
# Or phpstan.neon
parameters:
level: max
paths:
- src
treatPhpDocTypesAsCertain: falseAI rules
.cursor/rules/t1-domain-driven-types.mdc--- alwaysApply: true --- ## Prickles T1: Domain-Driven Types 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. Prefer a discriminated union with one tag per legal state to 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 is released, type the signatures and the comments retire themselves. The cost of changing a pervasive type is large only because the type is doing something. 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. 5
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 rather than 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.
- [4]Eric Evans: Domain-Driven Design (Addison-Wesley, 2003). The architectural complement. The model that types encode.
- [5]Martin Fowler: ValueObject (martinfowler.com/bliki, 2016). Pattern reference for the value-object idea that branded primitives implement at the type level.