--- name: form-machine description: Add a form or wizard as an Elm-style state machine (Model/Msg/pure reduce) — the one idiom for anything with validation or submission, one step or many. Use instead of hand-rolled mutable fields + ad-hoc error signals. --- # Form machine (Model / Msg / reduce) If you're about to add a second boolean to track state, stop — model a discriminated union. Fields exist only in the states that need them, so illegal states are unrepresentable. ## Skeleton `/domain/.machine.ts` (pure TS, no Angular imports): ```ts import { Result, assertNever } from '@shared/kernel/fp'; export interface Draft { postcode: string; uren: string; } // raw strings as typed export type StepErrors = Partial>; export interface Valid { postcode: Postcode; uren: Uren; } // branded, proven valid export type State = | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors } | { tag: 'Submitting'; data: Valid } | { tag: 'Submitted'; data: Valid } | { tag: 'Failed'; data: Valid; error: string }; export type Msg = | { tag: 'SetField'; key: keyof Draft; value: string } | { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } | { tag: 'Seed'; state: State }; // mount any state (stories, resume) export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} }; export function reduce(s: State, m: Msg): State { switch (m.tag) { /* … pure transitions only … */ default: return assertNever(m); // exhaustiveness enforced } } export function validate(draft: Draft): Result { /* calls value-object parsers */ } ``` Rules: - **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill). - **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field. - Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine). - Co-located `.machine.spec.ts` is **required**: drive `reduce` with messages, assert states. No TestBed. ## Wiring in the UI The organism holds `createStore(initial, reduce)` (`@shared/application/store`) as a field initializer, derives view state via `computed` + `whenTag(state, 'Editing')`, and renders into `` (`shared/layout/wizard-shell`) — status, steps, errors, and primary/back/retry outputs map 1:1 onto the machine. ## Worked examples - `src/app/herregistratie/domain/herregistratie.machine.ts` — canonical multi-step + submit lifecycle. - `src/app/herregistratie/domain/intake.machine.ts` — progressive disclosure, derive-don't-store, server-owned threshold. - `src/app/herregistratie/ui/` — the wizard organism + page composition. ## Verify ```bash npm test && npm run lint && npm run build ```