@structuralists/scaffolding 0.9.0 → 0.10.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -168,6 +168,30 @@ useFormState({
168
168
  });
169
169
  ```
170
170
 
171
+ ## The error model
172
+
173
+ Validation failures surface as a structured list, not a keyed record:
174
+
175
+ ```ts
176
+ type FormError<T> = { path: Path<T>; error: string }; // single-key paths on the flat grammar
177
+ type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
178
+ ```
179
+
180
+ Read one field's message with the typed accessor, never by hand-assembled
181
+ keys — serialized path strings (`'drivers.0.name'`) are deliberately not
182
+ exposed:
183
+
184
+ ```ts
185
+ errorAt(errors, ['email']); // string | undefined
186
+ ```
187
+
188
+ `errorAt` (useFormState/errorAt.ts) matches by exact structural step
189
+ equality (no prefix matching); with first-error-wins validation there is at
190
+ most one entry per path, and if collect-all ever lands the first entry stays
191
+ the one shown. The path-addressed model is what the recursive grammar
192
+ (errors just carry longer paths) and `getFormFieldPropsAt`'s `errorMessage`
193
+ build on.
194
+
171
195
  ## Union policy — what form state may hold
172
196
 
173
197
  The path machinery is load-bearing (structured `{path, error}[]` errors and
@@ -271,10 +295,16 @@ precision end-to-end. So we wire it up before adding any consumers.
271
295
  - `useFormState/` — the hook + the value-model types (`FormValueSimple`,
272
296
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
273
297
  onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`;
274
- `errors` is live-derived from current values each render (validators are
275
- pure and cheap), `submitAttempted` lets UIs gate error display, and
276
- `submit()` performs the one honest cast to `Refine<T, V>` earned because
277
- the validators just passed at runtime. `Debugger` is a per-instance
298
+ `errors` is the structured `FormErrors<T>` list (see "The error model"),
299
+ live-derived from current values each render (validators are pure and
300
+ cheap) with `isValid` its emptiness; `errorAt.ts` holds the typed lookup;
301
+ `submitAttempted` lets UIs gate error display; and `submit()` performs
302
+ the one honest *refinement* cast to `Refine<T, V>` — earned because the
303
+ validators just passed at runtime. The hook's error loop carries one
304
+ further documented widening (`failure.path as Path<T>`, same species as
305
+ its `Object.keys` cast): `[key]` is a valid single-key `Path<T>`, but TS
306
+ cannot compute `Path<T>` for an unresolved generic `T` to see the
307
+ correlation. `Debugger` is a per-instance
278
308
  dev-time overlay (fixed trigger, bottom-right, portaled to `<body>`) that
279
309
  opens a live `JsonTable` view of the form's internal state. Plumbing: the
280
310
  hook publishes a `FormDebugSnapshot` into a tiny `snapshotStore` after
package/src/forms/plan.md CHANGED
@@ -22,9 +22,15 @@ TS wall can't strand finished work behind it.
22
22
  3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
23
23
  are single-key paths). Doesn't depend on nested constraints; hard
24
24
  prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
25
- *current*
25
+ *done* — `FormError<T>`/`FormErrors<T>` (`{ path: Path<T>; error }[]`
26
+ in useFormState/types.ts), `isValid` derived from emptiness, and the
27
+ interim consumer accessor is a standalone `errorAt(errors, path)`
28
+ (useFormState/errorAt.ts) — the smallest surface that keeps stories
29
+ readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
30
+ `errorMessage`. The Debugger needed no change (`toInspectable`
31
+ index-keys arrays).
26
32
  4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
27
- item 6 so the wrappers land in their final home once.
33
+ item 6 so the wrappers land in their final home once. ← *current*
28
34
  5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
29
35
  error model and the split; `Path`/`ValueAt` already validated.
30
36
  6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
@@ -174,13 +180,19 @@ multi-second check time:
174
180
  - post-item-1 soundness fix (per-member-sound `MemberExcludes` in the array
175
181
  arm and `allOf`, plus its probes): check 0.77 s, 107,612 instantiations,
176
182
  48,507 types (+0.8%)
183
+ - pre-step-3 HEAD (union policy + validator arrays merged, PRs #17/#18):
184
+ check 0.78 s, 108,978 instantiations, 48,678 types
185
+ - post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
186
+ plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
187
+ (+4.4% instantiations over pre-step-3 HEAD)
177
188
 
178
189
  ## Runtime consequences (can't be dodged)
179
190
 
180
191
  - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
181
192
  `path/path.ts`; share the traversal or keep them deliberately parallel.
182
193
  - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
183
- Decided: errors become a plain list of structured entries,
194
+ *Landed with working-order step 3* (on the flat baseline, single-key
195
+ paths). Decided: errors become a plain list of structured entries,
184
196
 
185
197
  ```ts
186
198
  type FormError<T> = { path: Path<T>; error: string };
@@ -195,8 +207,11 @@ multi-second check time:
195
207
  as an *internal* implementation detail — the concatenation scheme must be
196
208
  fully encapsulated and opaque to everything outside it.
197
209
  - **`errors` display wiring** in stories/components goes through a typed
198
- accessor (e.g. `errorAt(errors, path)` / a cursor-based lookup), never
199
- through hand-assembled keys.
210
+ accessor, never through hand-assembled keys. *Decided and landed with
211
+ step 3*: a standalone `errorAt(errors, path)` (useFormState/errorAt.ts) —
212
+ structural step-equality lookup, first entry wins. Chosen over a
213
+ cursor-based lookup as the smallest surface that keeps stories readable
214
+ on the flat grammar; item 6's `errorMessage` reuses it internally.
200
215
 
201
216
  ## Phases
202
217
 
@@ -215,8 +230,9 @@ tests, story updates where visible, probe ratchet.
215
230
  *single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
216
231
  refinements, pinned in `validations/types.test-d.ts`).
217
232
  2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
218
- ratchet matters most here. Errors become `{path, error}[]` in this phase
219
- (nested fields need addresses).
233
+ ratchet matters most here. (The `{path, error}[]` error model already
234
+ landed in working-order step 3 — nested errors just carry longer paths;
235
+ no error plumbing changes in this phase.)
220
236
  3. **List `each` specs.** Runtime walks every element; error paths carry the
221
237
  numeric step (`['drivers', 3, 'name']`). Refined element type flows
222
238
  through `Array<...>`.
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { errorAt } from './errorAt';
3
+ import type { FormErrors } from './types';
4
+
5
+ // The flat grammar only produces single-key paths today, but errorAt's
6
+ // equality must already be exact over multi-step and numeric-step paths —
7
+ // the recursive grammar (plan phases 2–3) reuses it unchanged.
8
+ type Form = {
9
+ email: string | undefined;
10
+ address: { city: string | undefined };
11
+ tags: Array<{ label: string | undefined }>;
12
+ };
13
+
14
+ const errors: FormErrors<Form> = [
15
+ { path: ['email'], error: "'email' cannot be empty" },
16
+ { path: ['address', 'city'], error: "'city' cannot be empty" },
17
+ { path: ['tags', 0, 'label'], error: "'label' cannot be empty" },
18
+ ];
19
+
20
+ describe('errorAt', () => {
21
+ test('finds a single-key path', () => {
22
+ expect(errorAt(errors, ['email'])).toBe("'email' cannot be empty");
23
+ });
24
+
25
+ test('finds a nested path by exact step equality', () => {
26
+ expect(errorAt(errors, ['address', 'city'])).toBe(
27
+ "'city' cannot be empty",
28
+ );
29
+ });
30
+
31
+ test('numeric steps participate in equality', () => {
32
+ expect(errorAt(errors, ['tags', 0, 'label'])).toBe(
33
+ "'label' cannot be empty",
34
+ );
35
+ expect(errorAt(errors, ['tags', 1, 'label'])).toBeUndefined();
36
+ });
37
+
38
+ test('a prefix of an error path is not a match', () => {
39
+ expect(errorAt(errors, ['address'])).toBeUndefined();
40
+ expect(errorAt(errors, ['tags'])).toBeUndefined();
41
+ });
42
+
43
+ test('returns undefined when the list has no entry for the path', () => {
44
+ expect(errorAt([], ['email'])).toBeUndefined();
45
+ });
46
+
47
+ test('first entry wins when a path has several errors', () => {
48
+ const duplicated: FormErrors<Form> = [
49
+ { path: ['email'], error: 'first' },
50
+ { path: ['email'], error: 'second' },
51
+ ];
52
+ expect(errorAt(duplicated, ['email'])).toBe('first');
53
+ });
54
+ });
@@ -0,0 +1,27 @@
1
+ import type { Path, PathStep } from '../path/types';
2
+ import type { FormErrors, FormValuesObject } from './types';
3
+
4
+ // Typed lookup into the structured `{ path, error }[]` error list — the
5
+ // sanctioned way to read one field's error. Path equality is structural:
6
+ // same steps, same order, no prefix matching. With first-error-wins
7
+ // validation there is at most one entry per path today; should collect-all
8
+ // ever land, the first entry stays the one shown.
9
+ export const errorAt = <T extends FormValuesObject>(
10
+ errors: FormErrors<T>,
11
+ path: Path<T>,
12
+ ): string | undefined => {
13
+ // Path<T> is always a PathStep tuple; the conditional type just can't
14
+ // prove it for an unresolved T. Same honest widening as `Cursor.at`.
15
+ const steps = path as readonly PathStep[];
16
+
17
+ const match = errors.find((candidate) => {
18
+ const candidateSteps = candidate.path as readonly PathStep[];
19
+
20
+ return (
21
+ candidateSteps.length === steps.length &&
22
+ candidateSteps.every((step, index) => step === steps[index])
23
+ );
24
+ });
25
+
26
+ return match?.error;
27
+ };
@@ -1,6 +1,7 @@
1
- // Type-only import; FormDebugger.tsx imports value types from this file in
2
- // turn, but the cycle never exists at runtime.
1
+ // Type-only imports; FormDebugger.tsx and path/types.ts import value types
2
+ // from this file in turn, but the cycles never exist at runtime.
3
3
  import type { FormDebuggerComponent } from './FormDebugger';
4
+ import type { Path } from '../path/types';
4
5
 
5
6
  export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
6
7
 
@@ -76,9 +77,18 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
76
77
  }
77
78
  : unknown;
78
79
 
79
- export type FormErrors<T extends FormValuesObject> = Partial<
80
- Record<keyof T, string>
81
- >;
80
+ // Structured error model: one entry per failing constrained node, addressed
81
+ // by a typed path (single-key paths on the flat grammar; deeper addresses
82
+ // arrive with the recursive grammar). Deliberately a plain list — at form
83
+ // scale a linear scan is fine, and serialized string keys ('drivers.0.name')
84
+ // are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
85
+ // never by hand-assembled keys.
86
+ export type FormError<T extends FormValuesObject> = {
87
+ readonly path: Path<T>;
88
+ readonly error: string;
89
+ };
90
+
91
+ export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
82
92
 
83
93
  // What the hook publishes to its Debugger after every commit. Snapshots are
84
94
  // replaced whole (never mutated) so `useSyncExternalStore` consumers can
@@ -2,6 +2,8 @@ import { useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { expect, userEvent, within } from 'storybook/test';
4
4
  import { useFormState } from './useFormState';
5
+ import { errorAt } from './errorAt';
6
+ import type { FormErrors } from './types';
5
7
  import { matches, minLength, notEmpty } from '../validators/validators';
6
8
  import { Field } from '../../components/Forms/Field';
7
9
  import { Input } from '../../components/Forms/Input';
@@ -62,7 +64,11 @@ const SignupDemo = () => {
62
64
  onSubmit: (vals) => setSubmitted(vals),
63
65
  });
64
66
 
65
- const shownErrors = submitAttempted ? errors : {};
67
+ // errors is a structured `{ path, error }[]` list; a field's message is
68
+ // read with the typed `errorAt` accessor, never a hand-assembled key.
69
+ const shownErrors: FormErrors<SignupFormValues> = submitAttempted
70
+ ? errors
71
+ : [];
66
72
 
67
73
  return (
68
74
  <form
@@ -72,7 +78,11 @@ const SignupDemo = () => {
72
78
  submit();
73
79
  }}
74
80
  >
75
- <Field label="Email" error={shownErrors.email} htmlFor="signup-email">
81
+ <Field
82
+ label="Email"
83
+ error={errorAt(shownErrors, ['email'])}
84
+ htmlFor="signup-email"
85
+ >
76
86
  <Input
77
87
  id="signup-email"
78
88
  type="email"
@@ -88,7 +98,7 @@ const SignupDemo = () => {
88
98
  <Field
89
99
  label="Display name"
90
100
  hint="At least 3 characters"
91
- error={shownErrors.displayName}
101
+ error={errorAt(shownErrors, ['displayName'])}
92
102
  htmlFor="signup-display-name"
93
103
  >
94
104
  <Input
@@ -101,7 +111,7 @@ const SignupDemo = () => {
101
111
  />
102
112
  </Field>
103
113
 
104
- <Field label="Role" error={shownErrors.role}>
114
+ <Field label="Role" error={errorAt(shownErrors, ['role'])}>
105
115
  <SingleSelect
106
116
  options={ROLE_OPTIONS}
107
117
  value={values.role}
@@ -126,7 +136,7 @@ const SignupDemo = () => {
126
136
  <Button type="submit" variant="primary">
127
137
  Sign up
128
138
  </Button>
129
- {submitAttempted && Object.keys(errors).length > 0 && (
139
+ {submitAttempted && errors.length > 0 && (
130
140
  <span style={{ color: 'var(--ui-danger, #c33)', fontSize: 13 }}>
131
141
  Fix the highlighted fields
132
142
  </span>
@@ -209,7 +219,7 @@ const LiveValidityDemo = () => {
209
219
  <Field
210
220
  label="Nickname"
211
221
  hint="Errors here are live — not gated on a submit attempt"
212
- error={errors.nickname}
222
+ error={errorAt(errors, ['nickname'])}
213
223
  htmlFor="live-nickname"
214
224
  >
215
225
  <Input
@@ -274,7 +284,9 @@ const DebuggerDemo = () => {
274
284
  },
275
285
  });
276
286
 
277
- const shownErrors = submitAttempted ? errors : {};
287
+ const shownErrors: FormErrors<DebuggerDemoValues> = submitAttempted
288
+ ? errors
289
+ : [];
278
290
 
279
291
  return (
280
292
  <form
@@ -284,7 +296,11 @@ const DebuggerDemo = () => {
284
296
  submit();
285
297
  }}
286
298
  >
287
- <Field label="Email" error={shownErrors.email} htmlFor="debug-email">
299
+ <Field
300
+ label="Email"
301
+ error={errorAt(shownErrors, ['email'])}
302
+ htmlFor="debug-email"
303
+ >
288
304
  <Input
289
305
  id="debug-email"
290
306
  type="email"
@@ -297,7 +313,11 @@ const DebuggerDemo = () => {
297
313
  />
298
314
  </Field>
299
315
 
300
- <Field label="Nickname" error={shownErrors.nickname} htmlFor="debug-nickname">
316
+ <Field
317
+ label="Nickname"
318
+ error={errorAt(shownErrors, ['nickname'])}
319
+ htmlFor="debug-nickname"
320
+ >
301
321
  <Input
302
322
  id="debug-nickname"
303
323
  value={values.nickname ?? ''}
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expectTypeOf } from 'vitest';
2
2
  import { useFormState } from './useFormState';
3
+ import { errorAt } from './errorAt';
3
4
  import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
5
+ import type { FormErrors } from './types';
4
6
  import type { Refine, Validations } from '../validations/types';
5
7
  import type { Path, ValueAt } from '../path/types';
6
8
 
@@ -400,6 +402,29 @@ describe('useFormState narrowing at realistic scale', () => {
400
402
  >().toEqualTypeOf<boolean | undefined>();
401
403
  });
402
404
 
405
+ it('errors are the structured {path, error}[] list, looked up via errorAt', () => {
406
+ const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
407
+ expectTypeOf(form.errors).toEqualTypeOf<FormErrors<InsuranceQuoteForm>>();
408
+
409
+ // FormError paths are Path<T>-typed, so entries at any depth are legal
410
+ // values already — the recursive grammar reuses this model unchanged.
411
+ const errors: FormErrors<InsuranceQuoteForm> = [
412
+ { path: ['email'], error: 'x' },
413
+ { path: ['homeAddress', 'city'], error: 'x' },
414
+ { path: ['drivers', 0, 'incidents', 1, 'date'], error: 'x' },
415
+ ];
416
+
417
+ expectTypeOf(errorAt(errors, ['email'])).toEqualTypeOf<
418
+ string | undefined
419
+ >();
420
+ errorAt(errors, ['vehicles', 2, 'garagingAddress', 'postalCode']);
421
+
422
+ // @ts-expect-error 'emial' is not a field of the form
423
+ errorAt(errors, ['emial']);
424
+ // @ts-expect-error no paths exist below a scalar leaf
425
+ errorAt(errors, ['email', 'domain']);
426
+ });
427
+
403
428
  it('paths through optional sections and nullable lists resolve, at scale', () => {
404
429
  // The latent hole this pins: Path admitted these paths all along, but
405
430
  // ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect, mock } from 'bun:test';
2
2
  import { act, renderHook } from '@testing-library/react';
3
3
  import { useFormState } from './useFormState';
4
+ import { errorAt } from './errorAt';
4
5
  import { allOf, matches, notEmpty } from '../validators/validators';
5
6
 
6
7
  type SignupForm = {
@@ -39,13 +40,21 @@ describe('useFormState', () => {
39
40
  constraints: { email: notEmpty('email') },
40
41
  }),
41
42
  );
42
- expect(result.current.errors.email).toBe("'email' cannot be empty");
43
+ // The full structured shape: one entry per failing field, addressed by
44
+ // a typed path (single-key on the flat grammar).
45
+ expect(result.current.errors).toEqual([
46
+ { path: ['email'], error: "'email' cannot be empty" },
47
+ ]);
48
+ expect(errorAt(result.current.errors, ['email'])).toBe(
49
+ "'email' cannot be empty",
50
+ );
43
51
  expect(result.current.isValid).toBe(false);
44
52
 
45
53
  act(() => {
46
54
  result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
47
55
  });
48
- expect(result.current.errors.email).toBeUndefined();
56
+ expect(errorAt(result.current.errors, ['email'])).toBeUndefined();
57
+ expect(result.current.errors).toEqual([]);
49
58
  expect(result.current.isValid).toBe(true);
50
59
  });
51
60
 
@@ -56,13 +65,13 @@ describe('useFormState', () => {
56
65
  constraints: { email: notEmpty('email') },
57
66
  }),
58
67
  );
59
- expect(result.current.errors.nickname).toBeUndefined();
68
+ expect(errorAt(result.current.errors, ['nickname'])).toBeUndefined();
60
69
  });
61
70
 
62
71
  test('a form without constraints is always valid', () => {
63
72
  const { result } = renderHook(() => useFormState({ initialValues }));
64
73
  expect(result.current.isValid).toBe(true);
65
- expect(result.current.errors).toEqual({});
74
+ expect(result.current.errors).toEqual([]);
66
75
  });
67
76
 
68
77
  test('submit on an invalid form marks the attempt and skips onSubmit', () => {
@@ -119,17 +128,21 @@ describe('useFormState', () => {
119
128
  );
120
129
  // Both validators would fail on undefined-adjacent input paths; the
121
130
  // FIRST one's message surfaces.
122
- expect(result.current.errors.email).toBe("'email' cannot be empty");
131
+ expect(errorAt(result.current.errors, ['email'])).toBe(
132
+ "'email' cannot be empty",
133
+ );
123
134
 
124
135
  act(() => {
125
136
  result.current.onValueChanges((prev) => ({ ...prev, email: 'nope' }));
126
137
  });
127
- expect(result.current.errors.email).toBe("'email' must be a valid email");
138
+ expect(errorAt(result.current.errors, ['email'])).toBe(
139
+ "'email' must be a valid email",
140
+ );
128
141
 
129
142
  act(() => {
130
143
  result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
131
144
  });
132
- expect(result.current.errors.email).toBeUndefined();
145
+ expect(errorAt(result.current.errors, ['email'])).toBeUndefined();
133
146
  expect(result.current.isValid).toBe(true);
134
147
  });
135
148
 
@@ -172,7 +185,9 @@ describe('useFormState', () => {
172
185
  }),
173
186
  );
174
187
  expect(result.current.values.agreed).toBe(false);
175
- expect(result.current.errors.agreed).toBe('you must agree to the terms');
188
+ expect(errorAt(result.current.errors, ['agreed'])).toBe(
189
+ 'you must agree to the terms',
190
+ );
176
191
  expect(result.current.isValid).toBe(false);
177
192
 
178
193
  act(() => {
@@ -183,7 +198,7 @@ describe('useFormState', () => {
183
198
  act(() => {
184
199
  result.current.onValueChanges((prev) => ({ ...prev, agreed: true }));
185
200
  });
186
- expect(result.current.errors.agreed).toBeUndefined();
201
+ expect(errorAt(result.current.errors, ['agreed'])).toBeUndefined();
187
202
  expect(result.current.isValid).toBe(true);
188
203
 
189
204
  act(() => {
@@ -200,7 +215,7 @@ describe('useFormState', () => {
200
215
  const { result } = renderHook(() =>
201
216
  useFormState({ initialValues, constraints }),
202
217
  );
203
- expect(result.current.errors).toEqual({});
218
+ expect(result.current.errors).toEqual([]);
204
219
  expect(result.current.isValid).toBe(true);
205
220
  });
206
221
 
@@ -5,11 +5,12 @@ import { createSnapshotStore } from './snapshotStore';
5
5
  import type { SnapshotStore } from './snapshotStore';
6
6
  import type {
7
7
  FormDebugSnapshot,
8
- FormErrors,
8
+ FormError,
9
9
  FormHelpers,
10
10
  FormValuesObject,
11
11
  UnionPolicyCheck,
12
12
  } from './types';
13
+ import type { Path } from '../path/types';
13
14
  import type { Refine, Validations } from '../validations/types';
14
15
  import { validateEntry } from '../validations/walk';
15
16
  import type { FlatConstraintEntry } from '../validations/walk';
@@ -43,7 +44,7 @@ export const useFormState = <
43
44
  const [values, onValueChanges] = useState<T>(initialValues);
44
45
  const [submitAttempted, setSubmitAttempted] = useState(false);
45
46
 
46
- const errors: FormErrors<T> = {};
47
+ const errors: FormError<T>[] = [];
47
48
  if (constraints) {
48
49
  for (const key of Object.keys(constraints) as (keyof T & string)[]) {
49
50
  // No cast: if the grammar grows a constraint form the walk doesn't
@@ -51,11 +52,17 @@ export const useFormState = <
51
52
  const entry: FlatConstraintEntry | undefined = constraints[key];
52
53
  if (entry == null) continue;
53
54
  const failure = validateEntry(entry, values[key], [key]);
54
- if (failure != null) errors[key] = failure.error;
55
+ if (failure == null) continue;
56
+ // The walk returns the address it was handed, and `[key]` — a key of
57
+ // a constraints object type-checked against T — is a valid single-key
58
+ // Path<T>. TS can't compute Path<T> for an unresolved generic T, so
59
+ // the correlation needs the same honest widening as the keys cast
60
+ // above.
61
+ errors.push({ path: failure.path as Path<T>, error: failure.error });
55
62
  }
56
63
  }
57
64
 
58
- const isValid = Object.keys(errors).length === 0;
65
+ const isValid = errors.length === 0;
59
66
 
60
67
  // Debugger plumbing: one store + one component per hook instance, created
61
68
  // lazily on first render. The component's identity must be stable across
@@ -3,8 +3,9 @@ import type { PathStep } from '../path/types';
3
3
  // The runtime walk over a constraints object, kept separate from the hook so
4
4
  // its semantics are unit-testable without React. Phase 1 grammar is flat —
5
5
  // every path has exactly one step — but errors already carry a `PathStep[]`
6
- // address so the `{path, error}[]` error model of plan phases 2–3 falls out
7
- // of this structure instead of forcing a second rewrite.
6
+ // address, which the hook's structured `{path, error}[]` error model
7
+ // (`FormErrors<T>`) consumes directly; the recursive grammar of plan phases
8
+ // 2–3 just hands the walk longer paths, no second rewrite.
8
9
 
9
10
  export type ValidationError = {
10
11
  readonly path: readonly PathStep[];