Runnable `npm run gen:value-object` / `gen:form-machine` (plop) that scaffold the two
pure-TS house patterns with a co-located spec: a branded value object + parseX (mirrors
postcode/bsn), and an Elm-style form/wizard machine (Draft/Valid/Errors + Editing/
Submitting/Submitted/Failed union + initial/pure reduce/assertNever). Prompts take
context + PascalCase name (positional-arg bypass); a post-action reminds to add the
English target for the generated $localize id. Templates in plop-templates/ (prettier-
ignored). Skills (value-object, form-machine) point at the generators. ui-component +
bff-endpoint stay skill-driven (Angular {{}} / backend + gen:api).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.3 KiB
name, description
| name | description |
|---|---|
| form-machine | 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.
Scaffold it: npm run gen:form-machine (WP-43) emits <name>.machine.ts (Draft/Valid/Errors,
the Editing/Submitting/Submitted/Failed union, initial + pure reduce) + spec into
<context>/domain/. Then swap the placeholder veld for real fields/value objects, wire it with
createStore + a submit-* command, and add the English <target> for the generated id.
Skeleton
<context>/domain/<name>.machine.ts (pure TS, no Angular imports):
import { Result, assertNever } from '@shared/kernel/fp';
export interface Draft {
postcode: string;
uren: string;
} // raw strings as typed
export type StepErrors = Partial<Record<keyof Draft, string>>;
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<StepErrors, Valid> {
/* 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_DEFAULTin the intake machine). - Co-located
.machine.spec.tsis required: drivereducewith 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 <app-wizard-shell> (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
npm test && npm run lint && npm run build