FP Code Design
Design-time catalog for functional work. The architect loads this to design the
domain algebra, the type-level model, and the error/effect tracks BEFORE DISTILL
authors ATs; the functional crafter loads the same SSOT for execution. This is
the WHAT-to-design subset — execution mechanics (naive/discover/freeze testing,
ORM/persistence mapping, language idioms) stay in nw-fp-algebra-driven-design,
nw-fp-domain-modeling, and the language skills below.
FP language skills — resolve by target language, never by default
| Language skill |
Languages |
PBT sibling (when one exists) |
nw-fp-clojure |
Clojure |
— |
nw-fp-erlang-elixir |
Erlang, Elixir |
nw-pbt-erlang-elixir |
nw-fp-fsharp |
F# |
nw-pbt-dotnet |
nw-fp-haskell |
Haskell |
nw-pbt-haskell |
nw-fp-kotlin |
Kotlin |
nw-pbt-jvm |
nw-fp-rust |
Rust |
nw-pbt-rust |
nw-fp-scala |
Scala |
nw-pbt-jvm |
nw-fp-typescript |
TypeScript, JavaScript |
nw-pbt-typescript |
Same shape as the Polyglot Adapter Matrix nw-test-design-mandates-layered-mechanics
already uses for PBT bindings — one row per target language, resolved from the
contract's own target evidence, never guessed. A language NOT in this table has
no dedicated FP-idiom skill: load this catalog and nw-fp-domain-modeling alone
(both are language-agnostic) rather than silently substituting a listed
language's skill — the wrong language's idioms are worse than none.
Algebra-Driven Design
Discover the API before implementing by specifying the rules (equations)
operations must satisfy. Rules generate property tests, reveal missing features,
and catch contradictions at design time (minutes) not production (days).
Design process (5 steps)
- Start with scope, not implementation — do not fix data structures upfront.
- Define observations first — how users extract information; observations
define equality (two values equal if no observation distinguishes them).
- Add operations incrementally — for each, write rules connecting it to
existing ones. The web of rules IS the design.
- Let messy rules signal problems — complex rules mean coarse building
blocks; decompose until each rule is near-trivial.
- Generalize aggressively — remove unnecessary type constraints; if
operations ignore contained values, parameterize over them.
Algebraic structures (recognize → reuse known rules)
| Structure |
Defining rule |
Design signal / use |
| Semigroup |
(a·b)·c = a·(b·c) (associative) |
Combining where parenthesization is irrelevant (concat, min/max, config merge) |
| Monoid |
Associative + identity e·x = x = x·e |
Safe defaults, fold/reduce over collections ((+,0), (concat,[])) |
| Semilattice |
Associative + commutative + idempotent |
Conflict resolution, CRDTs, eventually-consistent merges (max) |
| Functor |
Preserves identity + composition under map |
Operations agnostic to the contained type |
| Applicative |
Element-wise combine + uniform fill |
Combining containers of differing content |
| Group |
Monoid + inverse x·x⁻¹ = e |
Undo, reversible spatial transforms |
Heuristic: associative? look for an identity → monoid. Have identity? check
commutativity/inverse → semilattice/group. Each upgrade unlocks new rules.
API design properties (8, three categories)
| Category |
Properties |
| Clarity |
Compositional · Task-relevant · Interrelated (rules link every operation) |
| Economy |
Parsimonious · Orthogonal · Generalized (no needless type constraints) |
| Safety |
Closed (valid construction ⇒ valid semantics) · Complete (max structure discovered) |
Decision tree — is algebraic thinking worth it?
- Domain about COMBINING → rules (order/defaults/inverses) map to a known structure.
- Domain about TRANSFORMING → look for Functor / structure-preserving patterns.
- Small, well-understood surface → conventional design (algebra adds overhead).
- Otherwise → rules still clarify, even without standard structures.
Domain Modelling with Types
Make illegal states unrepresentable; model workflows as pipelines; push errors to
the type level. Every rule encoded in a type needs no unit test.
Building blocks and wrappers
- AND (record types) — value has ALL fields (Order = CustomerInfo AND
Address AND OrderLines).
- OR (choice types) — value is ONE OF alternatives (ProductCode = Widget OR
Gizmo). Compose recursively to express any domain structure.
- Domain wrappers — never use raw primitives in the domain; wrap each
concept so the compiler distinguishes
CustomerId from OrderId. The type
name is the documentation.
- Smart constructors — private raw constructor; a
create validates and
returns a Result. Once constructed, a value is guaranteed valid — no
defensive checks downstream.
Make illegal states unrepresentable
| Smell |
Fix |
{ Email; IsVerified: bool } flag |
Distinct VerifiedEmail / UnverifiedEmail types; verification-requiring functions take VerifiedEmail |
{ Email: option; Address: option } (both could be None) |
Choice type EmailOnly | AddressOnly | EmailAndAddress — "at least one" enforced structurally |
| List that must be non-empty |
NonEmptyList<T> — zero-element state cannot be constructed |
Workflow as pipeline
Every workflow is one function: command in, events out. Decompose into stateless,
pure, single-input/output steps, each transforming one document type to the next:
UnvalidatedOrder → ValidatedOrder → PricedOrder → Events
Each step name is a domain concept; each step is independently testable.
State machine with types
Model each lifecycle stage / state as a separate type; a top-level choice type
unifies them (Cart = Empty | Active of ActiveData | Paid of PaidData).
Transition functions pattern-match the current state and return the next.
Benefits: all states explicit, per-state data, invalid transitions rejected by
types, exhaustiveness warnings reveal unhandled cases. New states (e.g.
Refunded) add without breaking existing code.
Dependencies and naming
- Declare each step's dependencies as leading parameters, primary input last
(enables partial application = functional DI). Top-level workflow hides
dependencies; internal steps make them explicit.
- Types as nouns · workflows as verbs · events past-tense · commands imperative ·
lifecycle prefixes (
Unvalidated…/Validated…/Priced…).
Decision tree — how to model a concept?
Simple value with validation? → Domain Wrapper + Smart Constructor
One of several alternatives? → Choice Type (sum)
Groups several values? → Record Type (product)
Distinct lifecycle stages? → State Machine with Types
Transforms data through stages? → Workflow Pipeline
Railway
Each step returns a Result; the pipeline runs on two tracks (success/failure)
and short-circuits on the first failure. Design the error track up front.
rawInput
|> validateOrder -- Result<ValidOrder, Error>
|> bind calculateTotal -- Result<PricedOrder, Error>
|> bind checkInventory -- Result<ConfirmedOrder, Error>
|> map generateReceipt -- Result<Receipt, Error>
Combinators
| Combinator |
Role |
map |
Transform the success value (one-track → two-track) |
bind |
Chain a function that itself returns Result |
mapError |
Transform the error value (lift a step error into the common type) |
tee |
Side effect without changing the value (logging) |
Error classification (design decision per category)
| Category |
Examples |
Strategy |
| Domain errors |
Validation failure, out of stock |
Model as types, return via Result |
| Panics |
Out of memory, null reference |
Throw; catch at top level |
| Infrastructure errors |
Network timeout, auth failure |
Case-by-case |
Design rules
Unify error types — define one common error choice type; mapError to
lift each step's error before composing.
Accumulate when the user needs all errors — use Applicative validation
(runs all checks, collects errors into a list) for forms / batch input;
standard bind short-circuits on the first.
Document effects in signatures — Result for errors, Async for I/O,
Option for missing data; the signature is the contract.
Declared inputs — what does this function READ that nobody passed it?
The FP reading of contract:declared-inputs-not-ambient-reads (SSOT:
nw-cross-cutting-invariants — the gate list and the anchor live there).
For every gate that clause lists, decide in the signature: a parameter,
a reader environment, an injected capability — with the ambient lookup kept
as a default the caller may state.
A function whose result depends on state absent from its arguments is not
pure, whatever its body looks like, and no amount of Result in the return
type recovers that. It also cannot be property-tested honestly: the generator
cannot reach the cases the ambient state is silently fixing, so the suite looks
thorough while the interesting partition is unreachable.
Test-side mirror: the Algebraic Analysis Before the Scenario mandate
(nw-test-design-mandates), its declared-inputs question.
Count the outcomes before you choose the return type.
How many outcomes does this operation have — and does its signature carry
every one of them? Answering "it returns the value and raises on the bad
case" is the wrong answer: that is N-1 outcomes in the type and one in the
control flow. If any outcome is not in the return type, put it there —
a sum type with one case per outcome, illegal combinations unconstructible.
Raising is a control-transfer effect, so an operation that raises is not
total. Two costs, and the second is the one that bites in a polyglot host:
- Composition. A raising step cannot be
bind-ed. Every caller needs an
imperative wrapper and the railway degrades to one track with hidden exits.
- Identity.
except/catch matches on the error's class identity. Where
the same module is reachable by more than one path — a source tree plus an
installed runtime, a harness that adjusts the import path, a plugin cache —
that identity is not guaranteed, the handler fails to match, and a handled
outcome escapes as a crash in an environment nobody tested. A returned sum
type has no identity to mismatch.
Keep raising for a programming error — a broken precondition, an
import-time drift guard — where crashing IS the correct outcome and no handler
is meant to cross a module boundary.
Empirical anchor 2026-08-06 (des.cli.phases): a resolver documented three
outcomes, returned two and threw the third. CI showed the exception escaping
the try whose very next line was the matching except. Returning all three
closed the class WITHOUT establishing why the module had loaded twice — and a
fix that does not depend on that answer is a design fix, not a patch.
Cross-cutting invariants (load them — they are not restated here)
Paradigm- and role-independent rules live in ONE shipped home: nw-cross-cutting-invariants.
Load it alongside this skill and honour these clauses by id — they are NOT duplicated here:
data:consumer-known-before-produced — a datum is produced only because a named consumer
reads it, and you must name the JOIN KEY it will be related on. No reader, or no key → the
datum is unjustified.
gate:self-explaining-what-why-how — every rejection states WHAT / WHY / HOW.
gate:design-principles-gdp-1-9 — the canonical gate-design contract.
1---2name: nw-code-design-fp3description: FP code-design SSOT — the WHAT-to-design catalog (algebra-driven design, domain modelling with types, railway/error-track isolation) shared by the solution architect (design-time) and the functional crafter (execution-time).4---56# FP Code Design78Design-time catalog for functional work. The architect loads this to design the9domain algebra, the type-level model, and the error/effect tracks BEFORE DISTILL10authors ATs; the functional crafter loads the same SSOT for execution. This is11the WHAT-to-design subset — execution mechanics (naive/discover/freeze testing,12ORM/persistence mapping, language idioms) stay in `nw-fp-algebra-driven-design`,13`nw-fp-domain-modeling`, and the language skills below.1415### FP language skills — resolve by target language, never by default1617| Language skill | Languages | PBT sibling (when one exists) |18|---|---|---|19| `nw-fp-clojure` | Clojure | — |20| `nw-fp-erlang-elixir` | Erlang, Elixir | `nw-pbt-erlang-elixir` |21| `nw-fp-fsharp` | F# | `nw-pbt-dotnet` |22| `nw-fp-haskell` | Haskell | `nw-pbt-haskell` |23| `nw-fp-kotlin` | Kotlin | `nw-pbt-jvm` |24| `nw-fp-rust` | Rust | `nw-pbt-rust` |25| `nw-fp-scala` | Scala | `nw-pbt-jvm` |26| `nw-fp-typescript` | TypeScript, JavaScript | `nw-pbt-typescript` |2728Same shape as the Polyglot Adapter Matrix `nw-test-design-mandates-layered-mechanics`29already uses for PBT bindings — one row per target language, resolved from the30contract's own target evidence, never guessed. A language NOT in this table has31no dedicated FP-idiom skill: load this catalog and `nw-fp-domain-modeling` alone32(both are language-agnostic) rather than silently substituting a listed33language's skill — the wrong language's idioms are worse than none.3435## Algebra-Driven Design3637Discover the API before implementing by specifying the rules (equations)38operations must satisfy. Rules generate property tests, reveal missing features,39and catch contradictions at design time (minutes) not production (days).4041### Design process (5 steps)42431. **Start with scope, not implementation** — do not fix data structures upfront.442. **Define observations first** — how users extract information; observations45 define equality (two values equal if no observation distinguishes them).463. **Add operations incrementally** — for each, write rules connecting it to47 existing ones. The web of rules IS the design.484. **Let messy rules signal problems** — complex rules mean coarse building49 blocks; decompose until each rule is near-trivial.505. **Generalize aggressively** — remove unnecessary type constraints; if51 operations ignore contained values, parameterize over them.5253### Algebraic structures (recognize → reuse known rules)5455| Structure | Defining rule | Design signal / use |56|-----------|---------------|---------------------|57| Semigroup | `(a·b)·c = a·(b·c)` (associative) | Combining where parenthesization is irrelevant (concat, min/max, config merge) |58| Monoid | Associative + identity `e·x = x = x·e` | Safe defaults, fold/reduce over collections (`(+,0)`, `(concat,[])`) |59| Semilattice | Associative + commutative + idempotent | Conflict resolution, CRDTs, eventually-consistent merges (`max`) |60| Functor | Preserves identity + composition under `map` | Operations agnostic to the contained type |61| Applicative | Element-wise combine + uniform fill | Combining containers of differing content |62| Group | Monoid + inverse `x·x⁻¹ = e` | Undo, reversible spatial transforms |6364Heuristic: associative? look for an identity → monoid. Have identity? check65commutativity/inverse → semilattice/group. Each upgrade unlocks new rules.6667### API design properties (8, three categories)6869| Category | Properties |70|----------|-----------|71| Clarity | Compositional · Task-relevant · Interrelated (rules link every operation) |72| Economy | Parsimonious · Orthogonal · Generalized (no needless type constraints) |73| Safety | Closed (valid construction ⇒ valid semantics) · Complete (max structure discovered) |7475### Decision tree — is algebraic thinking worth it?7677- Domain about COMBINING → rules (order/defaults/inverses) map to a known structure.78- Domain about TRANSFORMING → look for Functor / structure-preserving patterns.79- Small, well-understood surface → conventional design (algebra adds overhead).80- Otherwise → rules still clarify, even without standard structures.8182## Domain Modelling with Types8384Make illegal states unrepresentable; model workflows as pipelines; push errors to85the type level. Every rule encoded in a type needs no unit test.8687### Building blocks and wrappers88891. **AND (record types)** — value has ALL fields (Order = CustomerInfo AND90 Address AND OrderLines).912. **OR (choice types)** — value is ONE OF alternatives (ProductCode = Widget OR92 Gizmo). Compose recursively to express any domain structure.933. **Domain wrappers** — never use raw primitives in the domain; wrap each94 concept so the compiler distinguishes `CustomerId` from `OrderId`. The type95 name is the documentation.964. **Smart constructors** — private raw constructor; a `create` validates and97 returns a `Result`. Once constructed, a value is guaranteed valid — no98 defensive checks downstream.99100### Make illegal states unrepresentable101102| Smell | Fix |103|-------|-----|104| `{ Email; IsVerified: bool }` flag | Distinct `VerifiedEmail` / `UnverifiedEmail` types; verification-requiring functions take `VerifiedEmail` |105| `{ Email: option; Address: option }` (both could be None) | Choice type `EmailOnly \| AddressOnly \| EmailAndAddress` — "at least one" enforced structurally |106| List that must be non-empty | `NonEmptyList<T>` — zero-element state cannot be constructed |107108### Workflow as pipeline109110Every workflow is one function: command in, events out. Decompose into stateless,111pure, single-input/output steps, each transforming one document type to the next:112113```114UnvalidatedOrder → ValidatedOrder → PricedOrder → Events115```116117Each step name is a domain concept; each step is independently testable.118119### State machine with types120121Model each lifecycle stage / state as a separate type; a top-level choice type122unifies them (`Cart = Empty | Active of ActiveData | Paid of PaidData`).123Transition functions pattern-match the current state and return the next.124Benefits: all states explicit, per-state data, invalid transitions rejected by125types, exhaustiveness warnings reveal unhandled cases. New states (e.g.126`Refunded`) add without breaking existing code.127128### Dependencies and naming129130- Declare each step's dependencies as leading parameters, primary input last131 (enables partial application = functional DI). Top-level workflow hides132 dependencies; internal steps make them explicit.133- Types as nouns · workflows as verbs · events past-tense · commands imperative ·134 lifecycle prefixes (`Unvalidated…`/`Validated…`/`Priced…`).135136### Decision tree — how to model a concept?137138```139Simple value with validation? → Domain Wrapper + Smart Constructor140One of several alternatives? → Choice Type (sum)141Groups several values? → Record Type (product)142Distinct lifecycle stages? → State Machine with Types143Transforms data through stages? → Workflow Pipeline144```145146## Railway147148Each step returns a `Result`; the pipeline runs on two tracks (success/failure)149and short-circuits on the first failure. Design the error track up front.150151```152rawInput153 |> validateOrder -- Result<ValidOrder, Error>154 |> bind calculateTotal -- Result<PricedOrder, Error>155 |> bind checkInventory -- Result<ConfirmedOrder, Error>156 |> map generateReceipt -- Result<Receipt, Error>157```158159### Combinators160161| Combinator | Role |162|------------|------|163| `map` | Transform the success value (one-track → two-track) |164| `bind` | Chain a function that itself returns `Result` |165| `mapError` | Transform the error value (lift a step error into the common type) |166| `tee` | Side effect without changing the value (logging) |167168### Error classification (design decision per category)169170| Category | Examples | Strategy |171|----------|----------|----------|172| Domain errors | Validation failure, out of stock | Model as types, return via `Result` |173| Panics | Out of memory, null reference | Throw; catch at top level |174| Infrastructure errors | Network timeout, auth failure | Case-by-case |175176### Design rules1771781. **Unify error types** — define one common error choice type; `mapError` to179 lift each step's error before composing.1802. **Accumulate when the user needs all errors** — use Applicative validation181 (runs all checks, collects errors into a list) for forms / batch input;182 standard `bind` short-circuits on the first.1833. **Document effects in signatures** — `Result` for errors, `Async` for I/O,184 `Option` for missing data; the signature is the contract.1854. **Declared inputs — what does this function READ that nobody passed it?**186187 The FP reading of `contract:declared-inputs-not-ambient-reads` (SSOT:188 `nw-cross-cutting-invariants` — the gate list and the anchor live there).189190 > For every gate that clause lists, decide in the **signature**: a parameter,191 > a reader environment, an injected capability — with the ambient lookup kept192 > as a default the caller may state.193194 A function whose result depends on state absent from its arguments **is not195 pure**, whatever its body looks like, and no amount of `Result` in the return196 type recovers that. It also cannot be property-tested honestly: the generator197 cannot reach the cases the ambient state is silently fixing, so the suite looks198 thorough while the interesting partition is unreachable.199200 Test-side mirror: the Algebraic Analysis Before the Scenario mandate201 (`nw-test-design-mandates`), its declared-inputs question.2022035. **Count the outcomes before you choose the return type.**204205 > **How many outcomes does this operation have — and does its signature carry206 > every one of them?** Answering "it returns the value and raises on the bad207 > case" is the wrong answer: that is N-1 outcomes in the type and one in the208 > control flow. **If any outcome is not in the return type, put it there** —209 > a sum type with one case per outcome, illegal combinations unconstructible.210211 Raising is a control-transfer effect, so an operation that raises is not212 total. Two costs, and the second is the one that bites in a polyglot host:213214 - **Composition.** A raising step cannot be `bind`-ed. Every caller needs an215 imperative wrapper and the railway degrades to one track with hidden exits.216 - **Identity.** `except`/`catch` matches on the error's *class identity*. Where217 the same module is reachable by more than one path — a source tree plus an218 installed runtime, a harness that adjusts the import path, a plugin cache —219 that identity is not guaranteed, the handler fails to match, and a *handled*220 outcome escapes as a crash in an environment nobody tested. A returned sum221 type has no identity to mismatch.222223 Keep raising for a **programming error** — a broken precondition, an224 import-time drift guard — where crashing IS the correct outcome and no handler225 is meant to cross a module boundary.226227 Empirical anchor 2026-08-06 (`des.cli.phases`): a resolver documented three228 outcomes, returned two and threw the third. CI showed the exception escaping229 the `try` whose very next line was the matching `except`. Returning all three230 closed the class WITHOUT establishing why the module had loaded twice — and a231 fix that does not depend on that answer is a design fix, not a patch.232233## Cross-cutting invariants (load them — they are not restated here)234235Paradigm- and role-independent rules live in ONE shipped home: `nw-cross-cutting-invariants`.236Load it alongside this skill and honour these clauses by id — they are NOT duplicated here:237238- `data:consumer-known-before-produced` — a datum is produced only because a named consumer239 reads it, and you must name the JOIN KEY it will be related on. No reader, or no key → the240 datum is unjustified.241- `gate:self-explaining-what-why-how` — every rejection states WHAT / WHY / HOW.242- `gate:design-principles-gdp-1-9` — the canonical gate-design contract.