@structuralists/scaffolding 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ # Plan: composable, recursive `Validations`
2
+
3
+ Status: **proposed** — nothing below is built yet. The baseline (flat
4
+ `Validations<T>`, one validator per key, shallow `Refine`) is on
5
+ `forms/useformstate-baseline`.
6
+
7
+ ## Goal
8
+
9
+ Evolve `Validations<T>` so a constraints object can:
10
+
11
+ 1. hold **multiple validators per key** (an array),
12
+ 2. **recurse into nested object** form state,
13
+ 3. apply a spec **to each element of a list** (`FormValueList`),
14
+ 4. **compose** all of the above freely — while the refinement machinery
15
+ (`Refinement` markers → `Refine<T, V>` → narrowed `onSubmit`) keeps
16
+ working at every level.
17
+
18
+ Plus two items that ride on top, independent of the type work:
19
+
20
+ 5. split the forms area into **'form elements'** and **'form state'**
21
+ (see the section at the end),
22
+ 6. **field binding**: a `getFormFieldPropsAt(path)` helper plus
23
+ form-element shorthands (`TextInputForForm` style) so wiring a field is
24
+ one expression (see the section at the end).
25
+
26
+ ## Target grammar
27
+
28
+ What a key can map to is directed by the *field's* type, never guessed from
29
+ the constraint's shape (see "Disambiguation" below):
30
+
31
+ ```ts
32
+ type FieldValidator<F> = (val: F) => string | null; // possibly & Refinement<X>
33
+
34
+ type FieldConstraint<F> =
35
+ // leaf forms — allowed for any field type
36
+ | FieldValidator<F>
37
+ | readonly FieldValidator<F>[]
38
+ // structural forms — allowed only where the field type permits
39
+ | (F extends FormValuesObject ? Validations<F> : never)
40
+ | (F extends FormValueList ? ListConstraint<F[number]> : never);
41
+
42
+ type ListConstraint<Element extends FormValuesObject> = {
43
+ readonly each: Validations<Element>;
44
+ // room to grow, e.g.: readonly self?: readonly FieldValidator<Element[]>[]
45
+ // for list-level rules (min count, uniqueness) — NOT in scope for v1
46
+ };
47
+
48
+ type Validations<T extends FormValuesObject> = {
49
+ readonly [K in keyof T]?: FieldConstraint<T[K]>;
50
+ };
51
+ ```
52
+
53
+ Example of the full composition:
54
+
55
+ ```ts
56
+ useFormState({
57
+ initialValues: {} as InsuranceQuoteForm,
58
+ constraints: {
59
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')], // (1)
60
+ homeAddress: { // (2)
61
+ city: notEmpty('city'),
62
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
63
+ },
64
+ drivers: { // (3)
65
+ each: {
66
+ name: notEmpty('name'),
67
+ incidents: { each: { date: notEmpty('date') } }, // (4) nested list-in-list
68
+ },
69
+ },
70
+ },
71
+ onSubmit: (values) => {
72
+ values.email; // string
73
+ values.homeAddress.city; // string
74
+ values.drivers[0].incidents[0].date; // string
75
+ },
76
+ });
77
+ ```
78
+
79
+ ## Refinement through the grammar
80
+
81
+ `Refine` becomes recursive, mirroring the grammar. Sketch:
82
+
83
+ ```ts
84
+ type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
85
+
86
+ type RefineField<F, C> =
87
+ C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
88
+ : C extends readonly unknown[] ? Exclude<F, ExcludedOf<C[number]>> // validator array: union of excludes
89
+ : C extends { readonly each: infer E } ? F extends FormValueList
90
+ ? Array<RefineObject<F[number], E>> : F // per-element
91
+ : C extends object ? F extends FormValuesObject
92
+ ? RefineObject<F, C> : F // nested spec
93
+ : F; // bare fn / no marker
94
+
95
+ type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
96
+ ```
97
+
98
+ Branch-order constraints discovered in the baseline, which this sketch bakes
99
+ in:
100
+
101
+ - **Check `Refinement` before `object`** — validator functions are objects.
102
+ - **A bare function** (no marker) falls to the final `F` arm — narrows nothing.
103
+ - The runtime cast in `submit()` stays the same single honest cast: valid ⇒
104
+ every constrained node passed, at every depth, which is what the recursive
105
+ `Refine` now encodes.
106
+
107
+ ### Disambiguation
108
+
109
+ A `FormValuesObject` field could theoretically own a key named `each`, making
110
+ a nested `Validations` look like a `ListConstraint`. This never bites because
111
+ interpretation is **directed by `T`**: for an object-typed field only the
112
+ `Validations<F>` branch is live; for a list-typed field only `ListConstraint`.
113
+ Both the type level (`F extends ...` conditionals) and the runtime walker must
114
+ branch on the *value model*, never on constraint shape alone.
115
+
116
+ ## ⚠️ The recursion budget — the actual risk
117
+
118
+ The baseline's `Refine` is a single shallow mapped type; this plan makes it
119
+ recursive. Prior explorations of this design died on TS recursion limits with
120
+ realistic state, and a recursive `Refine` is the prime suspect for where that
121
+ happened. Discipline for every phase below:
122
+
123
+ - **Extend the recursion probe first** (`useFormState.test-d.ts`,
124
+ `InsuranceQuoteForm`): add the phase's construct at realistic scale *before*
125
+ implementing, so the failure mode is visible the moment it appears.
126
+ - Watch `tsc` wall-time, not just success — a 10× check-time regression is a
127
+ failure even if it compiles.
128
+ - If a phase hits the wall, stop and record the ceiling in the learning map
129
+ before reaching for tricks (interface-based lazy recursion, depth caps).
130
+
131
+ ## Runtime consequences (can't be dodged)
132
+
133
+ - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
134
+ `path/path.ts`; share the traversal or keep them deliberately parallel.
135
+ - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
136
+ Decided: errors become a plain list of structured entries,
137
+
138
+ ```ts
139
+ type FormError<T> = { path: Path<T>; error: string };
140
+ // errors: FormError<T>[] isValid: errors.length === 0
141
+ ```
142
+
143
+ reusing `path/`'s typed representation (this is the "surfacing per-field
144
+ errors" role path/ was built for). **We will never expose
145
+ `'drivers.0.name'`-style serialized-string key derivation.** At the scale
146
+ these forms operate, a linear scan over `{path, error}[]` is fine. If fast
147
+ lookup is ever genuinely needed, a string-keyed map is permitted strictly
148
+ as an *internal* implementation detail — the concatenation scheme must be
149
+ fully encapsulated and opaque to everything outside it.
150
+ - **`errors` display wiring** in stories/components goes through a typed
151
+ accessor (e.g. `errorAt(errors, path)` / a cursor-based lookup), never
152
+ through hand-assembled keys.
153
+
154
+ ## Phases
155
+
156
+ Each phase lands with: type tests (inline-call context on the chunky form —
157
+ contextual typing differs there, learned the hard way with `allOf`), runtime
158
+ tests, story updates where visible, probe ratchet.
159
+
160
+ 1. **Validator arrays per key.** Pure sugar over `allOf` at the constraint
161
+ site (`allOf` remains for building reusable composite validators).
162
+ First-error-wins per field, same as `allOf`. Excludes union via
163
+ `ExcludedOf<C[number]>`.
164
+ 2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
165
+ ratchet matters most here. Errors become `{path, error}[]` in this phase
166
+ (nested fields need addresses).
167
+ 3. **List `each` specs.** Runtime walks every element; error paths carry the
168
+ numeric step (`['drivers', 3, 'name']`). Refined element type flows
169
+ through `Array<...>`.
170
+ 4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
171
+ `perField` still the entry point for pre-built specs, docs
172
+ (forms/CLAUDE.md) updated to the new grammar.
173
+
174
+ ## 5. Split forms into 'form elements' and 'form state'
175
+
176
+ Today the form world lives in two places with unrelated names:
177
+
178
+ - `src/components/Forms/` — the presentational **elements** (`Input`,
179
+ `Field`, `Select`, `Button`, `Textarea`, …)
180
+ - `src/forms/` — the **state** layer (`useFormState`, `validations/`,
181
+ `validators/`, `path/`)
182
+
183
+ The intent: make that split explicit and named — 'form elements' vs
184
+ 'form state' — rather than the current accidental `Forms`-vs-`forms`
185
+ distinction. Recorded as direction; the target layout/naming is an open
186
+ decision (rename in place? move both under one `forms/` umbrella with
187
+ `elements/` and `state/` subtrees?).
188
+
189
+ Blast radius to account for when this happens (not now):
190
+
191
+ - the eslint `boundaries` element patterns in `eslint.config.mjs` are keyed
192
+ to `src/components/<Section>/<Component>/` shapes,
193
+ - barrel exports in `src/index.ts` (all the element components are public),
194
+ - Storybook titles (`Forms/...` prefix) and story globs,
195
+ - forms/CLAUDE.md and this plan's own paths.
196
+
197
+ Independent of phases 1–4; can land before, between, or after them.
198
+
199
+ ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
200
+
201
+ Today every field is wired by hand in JSX: read `values.x`, spread-update via
202
+ `onValueChanges`, look up the error, gate it on `submitAttempted`. That's
203
+ four decisions per field, all boilerplate. Target usage:
204
+
205
+ ```tsx
206
+ <TextInputForForm formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
207
+ ```
208
+
209
+ ### `getFormFieldPropsAt(path)`
210
+
211
+ A member of the hook's return value (it needs `values`, `errors`, and the
212
+ setter), typed by the `path/` machinery:
213
+
214
+ ```ts
215
+ type FormFieldProps<V> = {
216
+ value: V;
217
+ onChange: (val: V) => void;
218
+ errorMessage: string | undefined; // display-policy-aware (see below)
219
+ onBlur: () => void; // feeds touched tracking
220
+ // room to grow: name/id derivation, disabled, ...
221
+ };
222
+
223
+ // on FormHelpers<T>:
224
+ getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
225
+ ```
226
+
227
+ Consequences this pulls in deliberately:
228
+
229
+ - **Granular setters via path arrive here** — this retires the "likely
230
+ remove" todo on `onValueChanges` as the only write path. Writing at a path
231
+ is the immutable-update mirror of `read()` in `path/path.ts`.
232
+ - **Touched tracking becomes real state** (`onBlur` feeds it). The
233
+ error-display policy (submitAttempted / touched gating) moves *inside*
234
+ `errorMessage`, so consuming elements render what they're given and stay
235
+ policy-free.
236
+ - **`errorMessage` looks up the structured `{path, error}[]` list** by path
237
+ equality — the typed accessor from the error-model decision, not string
238
+ keys.
239
+ - Type safety composes end-to-end: `ValueAt<T, P>` types `value`/`onChange`,
240
+ so binding a `number`-typed path to a text-shaped element is a compile
241
+ error at the `formFieldProps` prop.
242
+
243
+ ### Element shorthands
244
+
245
+ Form elements get a form-aware flavor that accepts the bundle as one prop —
246
+ current lean: wrapper components (`TextInputForForm`, `SingleSelectForForm`,
247
+ …) taking `formFieldProps: FormFieldProps<V>` alongside the element's
248
+ presentational props. The alternative is union-typed props on the existing
249
+ elements themselves. **Deciding criterion (per Will): whichever is simpler
250
+ for agents to work with** — wrappers keep each component's prop surface
251
+ small and self-evident, unions avoid a parallel component set; prototype the
252
+ wrapper style first, `TextInputForForm` probably, but not settled.
253
+
254
+ Placement note: these wrappers are the bridge between 'form elements' and
255
+ 'form state', so where they live should fall out of the split in item 5 —
256
+ they are the one layer allowed to know about both sides.
257
+
258
+ ## Open decisions
259
+
260
+ - **Whole-value + structural constraints on the same key** (e.g. validate
261
+ `homeAddress` as a unit *and* its fields). Not expressible in this grammar.
262
+ Recommendation: punt from v1; if needed, later extend the structural forms
263
+ with a reserved `self` slot rather than overloading arrays.
264
+ - **Array semantics: first-error-wins vs collect-all.** v1: first-error-wins
265
+ (consistent with `allOf`). Collect-all becomes interesting only when the
266
+ UI can show multiple messages per field — note the `{path, error}[]` error
267
+ model already accommodates multiple entries per path when that day comes.
268
+
269
+ *(Resolved: error path format. Structured `{path, error}[]` with typed
270
+ paths — never exposed serialized-string keys. See "Runtime consequences".)*
@@ -6,8 +6,18 @@ export type FormValueList = FormValuesObject[];
6
6
 
7
7
  export type FormValue = FormValuesObject | FormValueList | FormValueSimple;
8
8
 
9
+ export type FormErrors<T extends FormValuesObject> = Partial<
10
+ Record<keyof T, string>
11
+ >;
12
+
9
13
  export type FormHelpers<T extends FormValuesObject> = {
10
14
  values: T;
11
15
  // todo: likely remove once other setter are available
12
16
  onValueChanges: (val: T | ((prev: T) => T)) => void;
17
+ // Live-derived from the current values on every render; UIs that only want
18
+ // errors after a submit attempt gate on `submitAttempted`.
19
+ errors: FormErrors<T>;
20
+ isValid: boolean;
21
+ submitAttempted: boolean;
22
+ submit: () => void;
13
23
  };
@@ -0,0 +1,196 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { useFormState } from './useFormState';
4
+ import { allOf, matches, minLength, notEmpty } from '../validators/validators';
5
+ import { Field } from '../../components/Forms/Field';
6
+ import { Input } from '../../components/Forms/Input';
7
+ import { Button } from '../../components/Forms/Button';
8
+ import { SingleSelect } from '../../components/Forms/Select';
9
+
10
+ const meta: Meta = {
11
+ title: 'Forms/useFormState',
12
+ };
13
+
14
+ export default meta;
15
+ type Story = StoryObj;
16
+
17
+ type SignupFormValues = {
18
+ email: string | undefined;
19
+ displayName: string | undefined;
20
+ role: string | null;
21
+ inviteCode: string | undefined;
22
+ };
23
+
24
+ // What onSubmit receives: SignupFormValues with each constrained field
25
+ // narrowed by its validator's refinement. `email`/`displayName` lose
26
+ // `undefined`, `role` loses `null`; `inviteCode` is unconstrained and keeps
27
+ // its full type. The `setSubmitted(vals)` call below only compiles because
28
+ // the hook actually delivers this narrowed type — the story doubles as a
29
+ // compile-time proof of the headline feature.
30
+ type SubmittedValues = {
31
+ email: string;
32
+ displayName: string;
33
+ role: string;
34
+ inviteCode: string | undefined;
35
+ };
36
+
37
+ const ROLE_OPTIONS = [
38
+ { value: 'engineer', label: 'Engineer' },
39
+ { value: 'designer', label: 'Designer' },
40
+ { value: 'manager', label: 'Manager' },
41
+ ];
42
+
43
+ const SignupDemo = () => {
44
+ const [submitted, setSubmitted] = useState<SubmittedValues | null>(null);
45
+
46
+ const { values, onValueChanges, errors, submitAttempted, submit } =
47
+ useFormState({
48
+ initialValues: {
49
+ email: undefined,
50
+ displayName: undefined,
51
+ role: null,
52
+ inviteCode: undefined,
53
+ } as SignupFormValues,
54
+ constraints: {
55
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
56
+ displayName: allOf(notEmpty('displayName'), minLength('displayName', 3)),
57
+ role: notEmpty('role'),
58
+ },
59
+ onSubmit: (vals) => setSubmitted(vals),
60
+ });
61
+
62
+ const shownErrors = submitAttempted ? errors : {};
63
+
64
+ return (
65
+ <form
66
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
67
+ onSubmit={(e) => {
68
+ e.preventDefault();
69
+ submit();
70
+ }}
71
+ >
72
+ <Field label="Email" error={shownErrors.email} htmlFor="signup-email">
73
+ <Input
74
+ id="signup-email"
75
+ type="email"
76
+ value={values.email ?? ''}
77
+ onChange={(e) => {
78
+ const email = e.target.value;
79
+ onValueChanges((prev) => ({ ...prev, email }));
80
+ }}
81
+ placeholder="you@example.com"
82
+ />
83
+ </Field>
84
+
85
+ <Field
86
+ label="Display name"
87
+ hint="At least 3 characters"
88
+ error={shownErrors.displayName}
89
+ htmlFor="signup-display-name"
90
+ >
91
+ <Input
92
+ id="signup-display-name"
93
+ value={values.displayName ?? ''}
94
+ onChange={(e) => {
95
+ const displayName = e.target.value;
96
+ onValueChanges((prev) => ({ ...prev, displayName }));
97
+ }}
98
+ />
99
+ </Field>
100
+
101
+ <Field label="Role" error={shownErrors.role}>
102
+ <SingleSelect
103
+ options={ROLE_OPTIONS}
104
+ value={values.role}
105
+ onChange={(role) => onValueChanges((prev) => ({ ...prev, role }))}
106
+ placeholder="Pick a role"
107
+ ariaLabel="Role"
108
+ />
109
+ </Field>
110
+
111
+ <Field label="Invite code" hint="Optional — no constraint on this field">
112
+ <Input
113
+ id="signup-invite-code"
114
+ value={values.inviteCode ?? ''}
115
+ onChange={(e) => {
116
+ const inviteCode = e.target.value;
117
+ onValueChanges((prev) => ({ ...prev, inviteCode }));
118
+ }}
119
+ />
120
+ </Field>
121
+
122
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
123
+ <Button type="submit" variant="primary">
124
+ Sign up
125
+ </Button>
126
+ {submitAttempted && Object.keys(errors).length > 0 && (
127
+ <span style={{ color: 'var(--ui-danger, #c33)', fontSize: 13 }}>
128
+ Fix the highlighted fields
129
+ </span>
130
+ )}
131
+ </div>
132
+
133
+ {submitted && (
134
+ <pre
135
+ style={{
136
+ background: 'var(--ui-surface-muted, #f4f4f4)',
137
+ padding: 12,
138
+ borderRadius: 6,
139
+ fontSize: 12,
140
+ margin: 0,
141
+ }}
142
+ >
143
+ {`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
144
+ </pre>
145
+ )}
146
+ </form>
147
+ );
148
+ };
149
+
150
+ export const SignupForm: Story = {
151
+ render: () => (
152
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
153
+ <SignupDemo />
154
+ </div>
155
+ ),
156
+ };
157
+
158
+ const LiveValidityDemo = () => {
159
+ const { values, onValueChanges, errors, isValid } = useFormState({
160
+ initialValues: { nickname: undefined } as { nickname: string | undefined },
161
+ constraints: {
162
+ nickname: allOf(notEmpty('nickname'), minLength('nickname', 3)),
163
+ },
164
+ });
165
+
166
+ return (
167
+ <div style={{ maxWidth: 420, display: 'grid', gap: 12 }}>
168
+ <Field
169
+ label="Nickname"
170
+ hint="Errors here are live — not gated on a submit attempt"
171
+ error={errors.nickname}
172
+ htmlFor="live-nickname"
173
+ >
174
+ <Input
175
+ id="live-nickname"
176
+ value={values.nickname ?? ''}
177
+ onChange={(e) => {
178
+ const nickname = e.target.value;
179
+ onValueChanges((prev) => ({ ...prev, nickname }));
180
+ }}
181
+ />
182
+ </Field>
183
+ <div style={{ fontSize: 13 }}>
184
+ isValid: <strong>{String(isValid)}</strong>
185
+ </div>
186
+ </div>
187
+ );
188
+ };
189
+
190
+ export const LiveValidity: Story = {
191
+ render: () => (
192
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
193
+ <LiveValidityDemo />
194
+ </div>
195
+ ),
196
+ };
@@ -0,0 +1,209 @@
1
+ import { describe, it, expectTypeOf } from 'vitest';
2
+ import { useFormState } from './useFormState';
3
+ import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
4
+ import type { Refine, Validations } from '../validations/types';
5
+ import type { Path, ValueAt } from '../path/types';
6
+
7
+ // These tests exercise the headline feature end-to-end at the hook boundary:
8
+ // the type `onSubmit` receives must be the *refined* form type. The two
9
+ // call-site shapes under test:
10
+ // 1. inline constraints — no ceremony at all; the hook's `const V` type
11
+ // parameter preserves each validator's Refinement marker
12
+ // 2. pre-built constraints — `as const satisfies Validations<T>` outside
13
+ // the call
14
+
15
+ describe('useFormState onSubmit narrowing — inline constraints', () => {
16
+ it('narrows a refined field and passes others through, with no call-site ceremony', () => {
17
+ useFormState({
18
+ initialValues: {
19
+ a: undefined as string | undefined,
20
+ b: 0,
21
+ c: null as string | null,
22
+ },
23
+ constraints: {
24
+ a: notEmpty('a'),
25
+ c: notEmpty('c'),
26
+ },
27
+ onSubmit: (values) => {
28
+ expectTypeOf(values).toEqualTypeOf<{
29
+ a: string;
30
+ b: number;
31
+ c: string;
32
+ }>();
33
+ },
34
+ });
35
+ });
36
+
37
+ it('bare inline validator functions narrow nothing', () => {
38
+ useFormState({
39
+ initialValues: { a: undefined as string | undefined },
40
+ constraints: {
41
+ a: (val) => (val ? null : 'required'),
42
+ },
43
+ onSubmit: (values) => {
44
+ expectTypeOf(values).toEqualTypeOf<{ a: string | undefined }>();
45
+ },
46
+ });
47
+ });
48
+
49
+ it('without constraints, onSubmit receives the unrefined form type', () => {
50
+ useFormState({
51
+ initialValues: { a: undefined as string | undefined, b: 0 },
52
+ onSubmit: (values) => {
53
+ expectTypeOf(values).toEqualTypeOf<{
54
+ a: string | undefined;
55
+ b: number;
56
+ }>();
57
+ },
58
+ });
59
+ });
60
+
61
+ it('rejects constraints for keys not in the form type', () => {
62
+ useFormState({
63
+ initialValues: { a: '' },
64
+ constraints: {
65
+ // @ts-expect-error 'nope' is not a field of the form
66
+ nope: notEmpty('nope'),
67
+ },
68
+ });
69
+ });
70
+
71
+ it('rejects a validator whose input is incompatible with the field', () => {
72
+ useFormState({
73
+ initialValues: { a: 0 },
74
+ constraints: {
75
+ // @ts-expect-error minLength validates strings, `a` is a number
76
+ a: minLength('a', 3),
77
+ },
78
+ });
79
+ });
80
+ });
81
+
82
+ describe('useFormState onSubmit narrowing — pre-built constraints', () => {
83
+ type FormType = {
84
+ email: string | undefined;
85
+ nickname: string | undefined;
86
+ };
87
+
88
+ it('preserves refinements from an `as const satisfies` constraints object', () => {
89
+ const constraints = {
90
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
91
+ } as const satisfies Validations<FormType>;
92
+
93
+ useFormState({
94
+ initialValues: { email: undefined, nickname: undefined } as FormType,
95
+ constraints,
96
+ onSubmit: (values) => {
97
+ expectTypeOf(values).toEqualTypeOf<{
98
+ email: string;
99
+ nickname: string | undefined;
100
+ }>();
101
+ },
102
+ });
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // The recursion probe. Prior explorations of this design narrowed fine on
108
+ // trivial examples but hit TS recursion limits on realistic form state. This
109
+ // form type is deliberately chunky — ~30 leaf fields, 3 levels of object
110
+ // nesting, lists of objects with nested objects inside. If the machinery
111
+ // scales, this file typechecks; if it regresses, tsc fails here first.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ type UsAddress = {
115
+ line1: string | undefined;
116
+ line2: string | undefined;
117
+ city: string | undefined;
118
+ state: string | undefined;
119
+ postalCode: string | undefined;
120
+ };
121
+
122
+ type InsuranceQuoteForm = {
123
+ firstName: string | undefined;
124
+ lastName: string | undefined;
125
+ email: string | undefined;
126
+ phone: string | undefined;
127
+ dateOfBirth: string | undefined;
128
+ homeAddress: UsAddress;
129
+ mailingAddress: UsAddress;
130
+ employer: string | null;
131
+ jobTitle: string | null;
132
+ yearsEmployed: number | null;
133
+ annualIncomeUsd: number | null;
134
+ coverageType: string | undefined;
135
+ deductibleUsd: number | undefined;
136
+ startDate: string | undefined;
137
+ drivers: Array<{
138
+ name: string | undefined;
139
+ licenseNumber: string | undefined;
140
+ licenseState: string | undefined;
141
+ incidents: Array<{
142
+ date: string | undefined;
143
+ kind: string | undefined;
144
+ claimAmountUsd: number | null;
145
+ }>;
146
+ }>;
147
+ vehicles: Array<{
148
+ vin: string | undefined;
149
+ year: number | undefined;
150
+ make: string | undefined;
151
+ model: string | undefined;
152
+ primaryDriverName: string | undefined;
153
+ garagingAddress: UsAddress;
154
+ }>;
155
+ discountCodes: string[];
156
+ referralSource: string | null;
157
+ notes: string | undefined;
158
+ };
159
+
160
+ describe('useFormState narrowing at realistic scale', () => {
161
+ it('narrows constrained fields on a ~30-field, 3-level-deep form', () => {
162
+ useFormState({
163
+ initialValues: {} as InsuranceQuoteForm,
164
+ constraints: {
165
+ firstName: notEmpty('firstName'),
166
+ lastName: notEmpty('lastName'),
167
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
168
+ phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
169
+ dateOfBirth: notEmpty('dateOfBirth'),
170
+ yearsEmployed: min('yearsEmployed', 0),
171
+ annualIncomeUsd: min('annualIncomeUsd', 0),
172
+ coverageType: notEmpty('coverageType'),
173
+ startDate: notEmpty('startDate'),
174
+ referralSource: notEmpty('referralSource'),
175
+ notes: minLength('notes', 10),
176
+ },
177
+ onSubmit: (values) => {
178
+ // Refined: notEmpty strips null/undefined/'' from the union.
179
+ expectTypeOf(values.firstName).toEqualTypeOf<string>();
180
+ expectTypeOf(values.email).toEqualTypeOf<string>();
181
+ expectTypeOf(values.referralSource).toEqualTypeOf<string>();
182
+ // Constrained but non-refining validators leave the type alone.
183
+ expectTypeOf(values.phone).toEqualTypeOf<string | undefined>();
184
+ expectTypeOf(values.annualIncomeUsd).toEqualTypeOf<number | null>();
185
+ expectTypeOf(values.notes).toEqualTypeOf<string | undefined>();
186
+ // Unconstrained fields — including all nested structure — untouched.
187
+ expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
188
+ expectTypeOf(values.drivers).toEqualTypeOf<
189
+ InsuranceQuoteForm['drivers']
190
+ >();
191
+ expectTypeOf(values.vehicles[0].garagingAddress).toEqualTypeOf<UsAddress>();
192
+ },
193
+ });
194
+ });
195
+
196
+ it('Path<T> still expands on the realistic form type', () => {
197
+ // Path is the most recursion-prone type in forms/ — its union grows with
198
+ // every key at every depth. Probe it with deep representative paths
199
+ // rather than equality on the (huge) full union.
200
+ type P = Path<InsuranceQuoteForm>;
201
+ expectTypeOf<['homeAddress', 'city']>().toMatchTypeOf<P>();
202
+ expectTypeOf<['drivers', number, 'incidents', number, 'claimAmountUsd']>().toMatchTypeOf<P>();
203
+ expectTypeOf<['vehicles', number, 'garagingAddress', 'postalCode']>().toMatchTypeOf<P>();
204
+
205
+ expectTypeOf<
206
+ ValueAt<InsuranceQuoteForm, ['drivers', number, 'incidents', number, 'claimAmountUsd']>
207
+ >().toEqualTypeOf<number | null>();
208
+ });
209
+ });