--- name: "domain-and-types" description: "Encode the domain in a structure instead of scattered conditionals, redesign around a new requirement instead of bolting it on, and let the type checker rule out impossible states: state machines, discriminated unions, branded primitives, parsing at the boundary, exhaustive matching. Use when writing stateful logic or a branch that grows by one more case, when a new requirement meets an existing design, and when designing a type or a function signature in any typed language." --- # Domain and types domain-and-types is the habit for the moment stateful logic is written, a branch grows by one more case, a new requirement meets an existing design, or a type or a function signature is designed, at whatever step the work is in, most often build. It writes no record of its own. Where data enters from outside, boundaries says where it is parsed and validated. Encode the real domain in a data structure instead of scattering it across conditionals. **Why:** Scattered booleans, repeated shape assumptions, and branching spread across files are accidental complexity. A structure that matches the domain makes invalid states unrepresentable and deletes branches. Choosing it at write time is cheap. Recovering it later reads as a refactor and gets deferred. **Reach for structures like these:** - A state machine instead of scattered booleans, phases, or lifecycle checks. - A typed object/model instead of loose parameters or repeated shape assumptions. - A map, registry, lookup table, or discriminated union instead of branching spread across files. - A reducer or command/event model instead of ad hoc state mutations. - A module organized around one body of domain knowledge instead of a sequence such as load, validate, transform, and save. Execution order is not ownership. - A small module boundary that gathers repeated behavior, ownership, or invariants. - A queue, cache, index, graph/tree, or normalized collection where the data access pattern calls for it. - Any other structure that fits. When none fits, work out what the code must never allow and how the data gets read, then find the structure that encodes exactly that. Do not force an abstraction. Prefer boring code if the current shape is already clear, local, and unlikely to grow. Be skeptical of an abstraction that adds indirection without removing branches, duplicated rules, invalid states, or lifecycle risk. The sign that you skipped this is a new feature that grows an existing if/else chain by one more branch, or a second boolean that must stay in sync with the first. Temporal decomposition is another sign. Phase-named modules repeat the same domain rules across steps. ## Redesign from first principles When integrating a change, don't bolt it onto the existing design. Redesign as if the requirement had been there from the start. - Read all affected files and understand the current design - Ask: "if we were writing this from scratch with this new requirement, what would we build?" - Propagate the change through every reference: types, docs, examples, rationale sections - Think about the whole redesign, then deliver it incrementally This is the method for preserving option value when integrating changes into an existing design. The redesign applies where the requirement changes the shape. Where the current shape is already clear, local and unlikely to grow, the boring code above stands. ## Types The type checker is a proof assistant. Use it to eliminate impossible states, mismatched primitives, and unhandled variants at compile time. A case the types let you ignore becomes a runtime failure the compiler could have stopped. Prefer defining errors and special cases out of existence over proliferating handlers. Unrepresentable states, total functions, and interface redesign (the patterns below) are the tools. **The patterns:** - **Make illegal states unrepresentable.** Model variants as sum types: discriminated unions in TypeScript, enums with payloads in Rust/Swift/Kotlin, sealed classes in Scala, ADTs in Haskell/OCaml. Don't model state as a bag of optional fields where contradictory combinations compile. A subtle anti-pattern: `{ completed: boolean; completedAt?: Date }` admits `completed: true; completedAt: undefined`, which is meaningless. Derive the boolean from a single source like `completedAt !== null`, or model the variants explicitly as `{ kind: 'open' } | { kind: 'done'; at: Date }`. If a bug forces the question "wait, can this combination actually happen?", the type is too loose. - **Types are constructions, not restrictions.** Build the type up from the values you want instead of carving them out of a looser type with checks. The invariant that seems to need a refinement type is usually a construction away. A non-empty list is a head plus a rest, not a list with a length check. A valid time range is a start plus a duration, not two timestamps you must keep ordered. No representation is privileged. A list of pairs is an even-length list if you interpret it that way, so choose the shape that cannot build the illegal value and expose the interface callers need on top. - **Brand semantic primitives.** `UserId` and `OrderId` are strings underneath but should not be interchangeable. Newtypes in Rust, opaque types in Swift, value classes in Kotlin, phantom types in Haskell, branded intersections in TypeScript. Validate once at creation, trust the type downstream. - **External data is untyped until parsed.** RPC payloads, JSON, IPC messages, CLI args, config files, environment variables, database rows. Have a parse function at every boundary that turns unstructured input into the typed model. See the **boundaries** habit for where to put validation. - **Don't lie to the type system.** Casts, unsafe coercions, and assertion functions that bypass the compiler are latent runtime crashes. If the compiler can't prove a fact, prove it (validate, narrow, refine the model) or accept that the cast is a hazard. - **Exhaustive matching is the compiler's job.** When you match on a sum type, the compiler must fail compilation if a new variant is added without handling. Use the idiom your language provides: `never`-typed binding in TypeScript, unannotated `match` in Rust, `-Wincomplete-patterns` in Haskell, sealed-class match exhaustiveness in Kotlin. - **Derive types from authoritative schemas.** When a protocol buffer, OpenAPI spec, GraphQL schema, database migration, or design-system token file defines a shape, derive from it instead of hand-rolling a parallel type. - **Strengthen a type only where partiality appears.** A runtime assertion, null check, or "this should never happen" throw marks the place a type is too weak. Push that check up into the type. Then stop. The type system's job is to track the cases each use site must handle, not to describe the data as precisely as possible. Prefer total functions. `sum` of an empty list is 0, so it takes the plain list. `head` of an empty list has no answer, so it demands the non-empty one. **The tests:** - "Can I write a comment explaining when this combination of fields is valid?" If yes, the type is too loose. Split it into a sum type. - "Do two of my function arguments share a primitive type but mean different things?" Brand them. - "Where did this `any`, this `as`, this `assertNotNull` come from?" Trace it to the boundary and validate there instead. - "If a new variant is added next month, will the compiler tell the next agent where to add a case?" If no, the match isn't exhaustive. - "Is this type duplicating a shape another file owns?" Derive instead. - "Am I strengthening this type to keep an operation total, or just to be more precise?" If nothing would otherwise panic, keep the plain type.