@structuralists/scaffolding 0.8.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 +1 -1
- package/src/forms/CLAUDE.md +97 -9
- package/src/forms/plan.md +48 -9
- package/src/forms/useFormState/errorAt.test.ts +54 -0
- package/src/forms/useFormState/errorAt.ts +27 -0
- package/src/forms/useFormState/types.ts +15 -5
- package/src/forms/useFormState/useFormState.stories.tsx +40 -17
- package/src/forms/useFormState/useFormState.test-d.ts +75 -5
- package/src/forms/useFormState/useFormState.test.tsx +81 -6
- package/src/forms/useFormState/useFormState.ts +18 -10
- package/src/forms/validations/perField.ts +4 -1
- package/src/forms/validations/types.test-d.ts +125 -0
- package/src/forms/validations/types.ts +78 -14
- package/src/forms/validations/walk.test.ts +75 -0
- package/src/forms/validations/walk.ts +52 -0
- package/src/forms/validators/validators.ts +7 -7
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -11,8 +11,9 @@ A form has three types in flight:
|
|
|
11
11
|
|
|
12
12
|
1. **`FormType`** — what fields exist and what they could hold. Often loose
|
|
13
13
|
(`{ a: string | undefined }`).
|
|
14
|
-
2. **`Validations<FormType>`** — a per-field map of
|
|
15
|
-
at the type level, refinement
|
|
14
|
+
2. **`Validations<FormType>`** — a per-field map of validators — a single
|
|
15
|
+
function or an ordered array of them — plus, at the type level, refinement
|
|
16
|
+
markers carried by each validator.
|
|
16
17
|
3. **`SubmitType = Refine<FormType, typeof validations>`** — `FormType` with
|
|
17
18
|
each field narrowed by the refinement that field's validator carries.
|
|
18
19
|
|
|
@@ -31,6 +32,28 @@ A validator is just:
|
|
|
31
32
|
No library, no fluent builder, no schema DSL. Composition is via plain
|
|
32
33
|
function composition.
|
|
33
34
|
|
|
35
|
+
## What a constraints key may map to
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
type FieldConstraint<F> =
|
|
39
|
+
| FieldValidator<F> // one validator
|
|
40
|
+
| readonly FieldValidator<F>[] // several, run in order, first error wins
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The array form is the everyday way to stack validators on a field at the
|
|
44
|
+
constraint site:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
constraints: {
|
|
48
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Semantics are identical to `allOf` (which remains the tool for building
|
|
53
|
+
*reusable, named* composite validators): validators run in array order,
|
|
54
|
+
the first error is the field's error, and later validators don't run once
|
|
55
|
+
one fails. An empty array passes and narrows nothing.
|
|
56
|
+
|
|
34
57
|
## Aggregation: `perField`
|
|
35
58
|
|
|
36
59
|
```ts
|
|
@@ -68,7 +91,7 @@ the widening failure mode all three patterns exist to avoid.
|
|
|
68
91
|
|
|
69
92
|
## Standard-library validators carry refinements
|
|
70
93
|
|
|
71
|
-
Built-in validators (e.g. `notEmpty`, `
|
|
94
|
+
Built-in validators (e.g. `notEmpty`, `minLength`, `matches`) expose a phantom
|
|
72
95
|
property whose type encodes the refinement they enforce. Sketch:
|
|
73
96
|
|
|
74
97
|
```ts
|
|
@@ -109,6 +132,32 @@ type C = typeof constraints; // a: notEmpty
|
|
|
109
132
|
type SubmitType = Refine<FormType, C>; // { a: string; b: number }
|
|
110
133
|
```
|
|
111
134
|
|
|
135
|
+
Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
|
|
136
|
+
is deliberately **naked** so unions of constraint types distribute. Two
|
|
137
|
+
regimes of "multiple markers per field" follow from that, both sound:
|
|
138
|
+
|
|
139
|
+
- **Validator array** — every member runs, so the field earns the *union of
|
|
140
|
+
the members' contributions* in one `Exclude`:
|
|
141
|
+
`Exclude<F, MemberExcludes<C>>`. Each member's contribution is itself
|
|
142
|
+
per-member sound (`SoundExcludedOf`): a member that is a *union* of
|
|
143
|
+
validators (`cond ? v1 : v2` inside the array) runs only one branch, so it
|
|
144
|
+
contributes only the **intersection** of its branches' excludes — what
|
|
145
|
+
every branch guarantees. A bare (marker-less) branch guarantees nothing,
|
|
146
|
+
so a union containing one contributes `never`. The naive
|
|
147
|
+
`ExcludedOf<C[number]>` would instead distribute over such a member and
|
|
148
|
+
over-claim both branches — never apply `ExcludedOf` to array members
|
|
149
|
+
directly. `allOf` computes its composite marker with the same
|
|
150
|
+
`MemberExcludes`, so both composition forms share these semantics.
|
|
151
|
+
- **Union-typed single constraint** (`cond ? notEmpty(…) : minLength(…)`) —
|
|
152
|
+
only one branch runs at runtime, so distribution yields the *union of the
|
|
153
|
+
per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
|
|
154
|
+
narrowing the running branch didn't earn.
|
|
155
|
+
|
|
156
|
+
Branch order inside `RefineField`: the `Refinement` check must stay ahead of
|
|
157
|
+
the array check and any future structural (object) check — validator
|
|
158
|
+
functions are objects, and a marked validator must not fall into a
|
|
159
|
+
structural arm.
|
|
160
|
+
|
|
112
161
|
## Hook surface
|
|
113
162
|
|
|
114
163
|
```ts
|
|
@@ -119,6 +168,30 @@ useFormState({
|
|
|
119
168
|
});
|
|
120
169
|
```
|
|
121
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
|
+
|
|
122
195
|
## Union policy — what form state may hold
|
|
123
196
|
|
|
124
197
|
The path machinery is load-bearing (structured `{path, error}[]` errors and
|
|
@@ -222,10 +295,16 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
222
295
|
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
223
296
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
224
297
|
onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`;
|
|
225
|
-
`errors` is
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
229
308
|
dev-time overlay (fixed trigger, bottom-right, portaled to `<body>`) that
|
|
230
309
|
opens a live `JsonTable` view of the form's internal state. Plumbing: the
|
|
231
310
|
hook publishes a `FormDebugSnapshot` into a tiny `snapshotStore` after
|
|
@@ -240,9 +319,18 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
240
319
|
Union handling is governed by the "Union policy" section above; the
|
|
241
320
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
242
321
|
`DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
|
|
243
|
-
- `validations/` — `perField`, `Validations<T
|
|
244
|
-
`Refinement<>` infra
|
|
322
|
+
- `validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
|
|
323
|
+
`Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
|
|
324
|
+
hook delegates to (`validateEntry`). `Validations<T>` accepts bare
|
|
245
325
|
`(val) => string | null` functions too — they simply narrow nothing.
|
|
326
|
+
The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
|
|
327
|
+
the hook's error loop: `constraints[key]` is *assigned* to it, never cast,
|
|
328
|
+
so a grammar form the walk doesn't understand (a nested spec, an `each`)
|
|
329
|
+
is a compile error at the assignment — a cast there would silently accept
|
|
330
|
+
new grammar and misinterpret it at runtime (e.g. call an array as a
|
|
331
|
+
function). Keep it cast-free. The one widening cast inside `validateEntry`
|
|
332
|
+
mirrors `allOf`'s part-call: honest contravariant widening of a single,
|
|
333
|
+
already-normalized validator.
|
|
246
334
|
- `validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
247
335
|
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
248
336
|
carries the union of the parts' refinements. `allOf` derives its input type
|
package/src/forms/plan.md
CHANGED
|
@@ -15,12 +15,22 @@ TS wall can't strand finished work behind it.
|
|
|
15
15
|
after it (watching nested state and structured errors live). ✅ *done*
|
|
16
16
|
(PR #14, released 0.6.0)
|
|
17
17
|
2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
|
|
18
|
-
proven inside `allOf`.
|
|
18
|
+
proven inside `allOf`. ✅ *done* — grammar (`FieldConstraint`), distributive
|
|
19
|
+
`RefineField`, and the walk rewrite (`validations/walk.ts`, cast-free entry
|
|
20
|
+
dispatch, `PathStep[]`-addressed errors) landed together; probe ratchet
|
|
21
|
+
held (see the baseline note under the recursion budget).
|
|
19
22
|
3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
|
|
20
23
|
are single-key paths). Doesn't depend on nested constraints; hard
|
|
21
24
|
prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
|
|
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).
|
|
22
32
|
4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
|
|
23
|
-
item 6 so the wrappers land in their final home once.
|
|
33
|
+
item 6 so the wrappers land in their final home once. ← *current*
|
|
24
34
|
5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
|
|
25
35
|
error model and the split; `Path`/`ValueAt` already validated.
|
|
26
36
|
6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
|
|
@@ -112,10 +122,12 @@ useFormState({
|
|
|
112
122
|
|
|
113
123
|
```ts
|
|
114
124
|
type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
|
|
125
|
+
// MemberExcludes<C>: the per-member-sound union of a validator tuple's
|
|
126
|
+
// excludes — it and SoundExcludedOf landed with phase 1 in validations/types.ts.
|
|
115
127
|
|
|
116
128
|
type RefineField<F, C> =
|
|
117
129
|
C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
|
|
118
|
-
: C extends readonly unknown[] ? Exclude<F,
|
|
130
|
+
: C extends readonly unknown[] ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
|
|
119
131
|
: C extends { readonly each: infer E } ? F extends FormValueList
|
|
120
132
|
? Array<RefineObject<F[number], E>> : F // per-element
|
|
121
133
|
: C extends object ? F extends FormValuesObject
|
|
@@ -158,12 +170,29 @@ happened. Discipline for every phase below:
|
|
|
158
170
|
- If a phase hits the wall, stop and record the ceiling in the learning map
|
|
159
171
|
before reaching for tricks (interface-based lazy recursion, depth caps).
|
|
160
172
|
|
|
173
|
+
Recorded baselines (`tsc --noEmit --extendedDiagnostics`), so drift is a diff
|
|
174
|
+
rather than a memory. The failure signal is ~10× instantiations or
|
|
175
|
+
multi-second check time:
|
|
176
|
+
|
|
177
|
+
- pre-item-1 (0.8.0): check 0.78 s, 102,415 instantiations, 46,089 types
|
|
178
|
+
- post-item-1 (arrays): check 0.77 s, 106,787 instantiations, 47,318 types
|
|
179
|
+
(+4.3% instantiations — the array grammar and its probes are nearly free)
|
|
180
|
+
- post-item-1 soundness fix (per-member-sound `MemberExcludes` in the array
|
|
181
|
+
arm and `allOf`, plus its probes): check 0.77 s, 107,612 instantiations,
|
|
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)
|
|
188
|
+
|
|
161
189
|
## Runtime consequences (can't be dodged)
|
|
162
190
|
|
|
163
191
|
- **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
|
|
164
192
|
`path/path.ts`; share the traversal or keep them deliberately parallel.
|
|
165
193
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
166
|
-
|
|
194
|
+
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
195
|
+
paths). Decided: errors become a plain list of structured entries,
|
|
167
196
|
|
|
168
197
|
```ts
|
|
169
198
|
type FormError<T> = { path: Path<T>; error: string };
|
|
@@ -178,8 +207,11 @@ happened. Discipline for every phase below:
|
|
|
178
207
|
as an *internal* implementation detail — the concatenation scheme must be
|
|
179
208
|
fully encapsulated and opaque to everything outside it.
|
|
180
209
|
- **`errors` display wiring** in stories/components goes through a typed
|
|
181
|
-
accessor
|
|
182
|
-
|
|
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.
|
|
183
215
|
|
|
184
216
|
## Phases
|
|
185
217
|
|
|
@@ -190,10 +222,17 @@ tests, story updates where visible, probe ratchet.
|
|
|
190
222
|
1. **Validator arrays per key.** Pure sugar over `allOf` at the constraint
|
|
191
223
|
site (`allOf` remains for building reusable composite validators).
|
|
192
224
|
First-error-wins per field, same as `allOf`. Excludes union via
|
|
193
|
-
`
|
|
225
|
+
`MemberExcludes<C>` — per-member sound: a union-typed member
|
|
226
|
+
(`cond ? v1 : v2` inside the array) earns only the *intersection* of its
|
|
227
|
+
branches' excludes, since only one branch runs; `allOf` shares the same
|
|
228
|
+
marker computation. ✅ *done* — plus a byproduct: `RefineField`
|
|
229
|
+
distributes over a naked constraint parameter, which made union-typed
|
|
230
|
+
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
231
|
+
refinements, pinned in `validations/types.test-d.ts`).
|
|
194
232
|
2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
|
|
195
|
-
ratchet matters most here.
|
|
196
|
-
|
|
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.)
|
|
197
236
|
3. **List `each` specs.** Runtime walks every element; error paths carry the
|
|
198
237
|
numeric step (`['drivers', 3, 'name']`). Refined element type flows
|
|
199
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
|
|
2
|
-
// turn, but the
|
|
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
|
-
|
|
80
|
-
|
|
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,7 +2,9 @@ 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 {
|
|
5
|
+
import { errorAt } from './errorAt';
|
|
6
|
+
import type { FormErrors } from './types';
|
|
7
|
+
import { matches, minLength, notEmpty } from '../validators/validators';
|
|
6
8
|
import { Field } from '../../components/Forms/Field';
|
|
7
9
|
import { Input } from '../../components/Forms/Input';
|
|
8
10
|
import { Button } from '../../components/Forms/Button';
|
|
@@ -53,14 +55,20 @@ const SignupDemo = () => {
|
|
|
53
55
|
inviteCode: undefined,
|
|
54
56
|
} as SignupFormValues,
|
|
55
57
|
constraints: {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
// Multiple validators per key: an array, run in order, first error
|
|
59
|
+
// wins. (`allOf` still exists for building reusable composites.)
|
|
60
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
61
|
+
displayName: [notEmpty('displayName'), minLength('displayName', 3)],
|
|
58
62
|
role: notEmpty('role'),
|
|
59
63
|
},
|
|
60
64
|
onSubmit: (vals) => setSubmitted(vals),
|
|
61
65
|
});
|
|
62
66
|
|
|
63
|
-
|
|
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
|
+
: [];
|
|
64
72
|
|
|
65
73
|
return (
|
|
66
74
|
<form
|
|
@@ -70,7 +78,11 @@ const SignupDemo = () => {
|
|
|
70
78
|
submit();
|
|
71
79
|
}}
|
|
72
80
|
>
|
|
73
|
-
<Field
|
|
81
|
+
<Field
|
|
82
|
+
label="Email"
|
|
83
|
+
error={errorAt(shownErrors, ['email'])}
|
|
84
|
+
htmlFor="signup-email"
|
|
85
|
+
>
|
|
74
86
|
<Input
|
|
75
87
|
id="signup-email"
|
|
76
88
|
type="email"
|
|
@@ -86,7 +98,7 @@ const SignupDemo = () => {
|
|
|
86
98
|
<Field
|
|
87
99
|
label="Display name"
|
|
88
100
|
hint="At least 3 characters"
|
|
89
|
-
error={shownErrors
|
|
101
|
+
error={errorAt(shownErrors, ['displayName'])}
|
|
90
102
|
htmlFor="signup-display-name"
|
|
91
103
|
>
|
|
92
104
|
<Input
|
|
@@ -99,7 +111,7 @@ const SignupDemo = () => {
|
|
|
99
111
|
/>
|
|
100
112
|
</Field>
|
|
101
113
|
|
|
102
|
-
<Field label="Role" error={shownErrors
|
|
114
|
+
<Field label="Role" error={errorAt(shownErrors, ['role'])}>
|
|
103
115
|
<SingleSelect
|
|
104
116
|
options={ROLE_OPTIONS}
|
|
105
117
|
value={values.role}
|
|
@@ -124,7 +136,7 @@ const SignupDemo = () => {
|
|
|
124
136
|
<Button type="submit" variant="primary">
|
|
125
137
|
Sign up
|
|
126
138
|
</Button>
|
|
127
|
-
{submitAttempted &&
|
|
139
|
+
{submitAttempted && errors.length > 0 && (
|
|
128
140
|
<span style={{ color: 'var(--ui-danger, #c33)', fontSize: 13 }}>
|
|
129
141
|
Fix the highlighted fields
|
|
130
142
|
</span>
|
|
@@ -154,8 +166,9 @@ export const SignupForm: Story = {
|
|
|
154
166
|
<SignupDemo />
|
|
155
167
|
</div>
|
|
156
168
|
),
|
|
157
|
-
// Walks the headline flow: errors gated on submit,
|
|
158
|
-
// progression, live clearing, and the narrowed payload
|
|
169
|
+
// Walks the headline flow: errors gated on submit, validator-array
|
|
170
|
+
// first-error progression, live clearing, and the narrowed payload
|
|
171
|
+
// reaching onSubmit.
|
|
159
172
|
play: async ({ canvasElement }) => {
|
|
160
173
|
const canvas = within(canvasElement);
|
|
161
174
|
const body = within(canvasElement.ownerDocument.body);
|
|
@@ -173,7 +186,7 @@ export const SignupForm: Story = {
|
|
|
173
186
|
).toBeInTheDocument();
|
|
174
187
|
await expect(canvas.getByText("'role' cannot be empty")).toBeInTheDocument();
|
|
175
188
|
|
|
176
|
-
//
|
|
189
|
+
// Array progression: notEmpty now passes, matches takes over.
|
|
177
190
|
await userEvent.type(canvas.getByLabelText(/^Email/), 'not-an-email');
|
|
178
191
|
await expect(
|
|
179
192
|
canvas.getByText("'email' must be a valid email"),
|
|
@@ -197,7 +210,7 @@ const LiveValidityDemo = () => {
|
|
|
197
210
|
const { values, onValueChanges, errors, isValid } = useFormState({
|
|
198
211
|
initialValues: { nickname: undefined } as { nickname: string | undefined },
|
|
199
212
|
constraints: {
|
|
200
|
-
nickname:
|
|
213
|
+
nickname: [notEmpty('nickname'), minLength('nickname', 3)],
|
|
201
214
|
},
|
|
202
215
|
});
|
|
203
216
|
|
|
@@ -206,7 +219,7 @@ const LiveValidityDemo = () => {
|
|
|
206
219
|
<Field
|
|
207
220
|
label="Nickname"
|
|
208
221
|
hint="Errors here are live — not gated on a submit attempt"
|
|
209
|
-
error={errors
|
|
222
|
+
error={errorAt(errors, ['nickname'])}
|
|
210
223
|
htmlFor="live-nickname"
|
|
211
224
|
>
|
|
212
225
|
<Input
|
|
@@ -266,12 +279,14 @@ const DebuggerDemo = () => {
|
|
|
266
279
|
tags: [],
|
|
267
280
|
} as DebuggerDemoValues,
|
|
268
281
|
constraints: {
|
|
269
|
-
email:
|
|
282
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
270
283
|
nickname: notEmpty('nickname'),
|
|
271
284
|
},
|
|
272
285
|
});
|
|
273
286
|
|
|
274
|
-
const shownErrors = submitAttempted
|
|
287
|
+
const shownErrors: FormErrors<DebuggerDemoValues> = submitAttempted
|
|
288
|
+
? errors
|
|
289
|
+
: [];
|
|
275
290
|
|
|
276
291
|
return (
|
|
277
292
|
<form
|
|
@@ -281,7 +296,11 @@ const DebuggerDemo = () => {
|
|
|
281
296
|
submit();
|
|
282
297
|
}}
|
|
283
298
|
>
|
|
284
|
-
<Field
|
|
299
|
+
<Field
|
|
300
|
+
label="Email"
|
|
301
|
+
error={errorAt(shownErrors, ['email'])}
|
|
302
|
+
htmlFor="debug-email"
|
|
303
|
+
>
|
|
285
304
|
<Input
|
|
286
305
|
id="debug-email"
|
|
287
306
|
type="email"
|
|
@@ -294,7 +313,11 @@ const DebuggerDemo = () => {
|
|
|
294
313
|
/>
|
|
295
314
|
</Field>
|
|
296
315
|
|
|
297
|
-
<Field
|
|
316
|
+
<Field
|
|
317
|
+
label="Nickname"
|
|
318
|
+
error={errorAt(shownErrors, ['nickname'])}
|
|
319
|
+
htmlFor="debug-nickname"
|
|
320
|
+
>
|
|
298
321
|
<Input
|
|
299
322
|
id="debug-nickname"
|
|
300
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
|
|
|
@@ -34,6 +36,18 @@ describe('useFormState onSubmit narrowing — inline constraints', () => {
|
|
|
34
36
|
});
|
|
35
37
|
});
|
|
36
38
|
|
|
39
|
+
it('narrows via an inline validator array with no call-site ceremony', () => {
|
|
40
|
+
useFormState({
|
|
41
|
+
initialValues: { a: undefined as string | undefined, b: 0 },
|
|
42
|
+
constraints: {
|
|
43
|
+
a: [notEmpty('a'), minLength('a', 3)],
|
|
44
|
+
},
|
|
45
|
+
onSubmit: (values) => {
|
|
46
|
+
expectTypeOf(values).toEqualTypeOf<{ a: string; b: number }>();
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
37
51
|
it('bare inline validator functions narrow nothing', () => {
|
|
38
52
|
useFormState({
|
|
39
53
|
initialValues: { a: undefined as string | undefined },
|
|
@@ -303,22 +317,42 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
303
317
|
initialValues: {} as InsuranceQuoteForm,
|
|
304
318
|
constraints: {
|
|
305
319
|
firstName: notEmpty('firstName'),
|
|
306
|
-
|
|
307
|
-
|
|
320
|
+
// Single-element array: refines exactly like the bare validator.
|
|
321
|
+
lastName: [notEmpty('lastName')],
|
|
322
|
+
// Validator array inline — the everyday composition site. Mixed
|
|
323
|
+
// markers: notEmpty refines, matches contributes never.
|
|
324
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
308
325
|
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
309
|
-
|
|
326
|
+
// allOf remains the tool for reusable composites; keep it probed at
|
|
327
|
+
// scale alongside the array sugar.
|
|
328
|
+
dateOfBirth: allOf(
|
|
329
|
+
notEmpty('dateOfBirth'),
|
|
330
|
+
matches('dateOfBirth', /^\d{4}-\d{2}-\d{2}$/, 'an ISO date'),
|
|
331
|
+
),
|
|
310
332
|
yearsEmployed: min('yearsEmployed', 0),
|
|
311
333
|
annualIncomeUsd: min('annualIncomeUsd', 0),
|
|
312
|
-
|
|
334
|
+
// A bare arrow inside an array: contextually typed by the field, and
|
|
335
|
+
// it must not dilute the marked member's refinement.
|
|
336
|
+
coverageType: [
|
|
337
|
+
notEmpty('coverageType'),
|
|
338
|
+
(val) => {
|
|
339
|
+
expectTypeOf(val).toEqualTypeOf<string | undefined>();
|
|
340
|
+
return null;
|
|
341
|
+
},
|
|
342
|
+
],
|
|
313
343
|
startDate: notEmpty('startDate'),
|
|
314
344
|
referralSource: notEmpty('referralSource'),
|
|
315
|
-
|
|
345
|
+
// Array of non-refining validators only.
|
|
346
|
+
notes: [minLength('notes', 10), matches('notes', /\S/, 'not blank')],
|
|
316
347
|
agreedToTerms: notEmpty('agreedToTerms'),
|
|
317
348
|
},
|
|
318
349
|
onSubmit: (values) => {
|
|
319
350
|
// Refined: notEmpty strips null/undefined/'' from the union.
|
|
320
351
|
expectTypeOf(values.firstName).toEqualTypeOf<string>();
|
|
352
|
+
expectTypeOf(values.lastName).toEqualTypeOf<string>();
|
|
321
353
|
expectTypeOf(values.email).toEqualTypeOf<string>();
|
|
354
|
+
expectTypeOf(values.dateOfBirth).toEqualTypeOf<string>();
|
|
355
|
+
expectTypeOf(values.coverageType).toEqualTypeOf<string>();
|
|
322
356
|
expectTypeOf(values.referralSource).toEqualTypeOf<string>();
|
|
323
357
|
expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
|
|
324
358
|
// Constrained but non-refining validators leave the type alone.
|
|
@@ -337,6 +371,19 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
337
371
|
});
|
|
338
372
|
});
|
|
339
373
|
|
|
374
|
+
it('rejects a wrong-input validator inside an array, at scale', () => {
|
|
375
|
+
useFormState({
|
|
376
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
377
|
+
constraints: {
|
|
378
|
+
yearsEmployed: [
|
|
379
|
+
min('yearsEmployed', 0),
|
|
380
|
+
// @ts-expect-error minLength validates strings; the field is number | null
|
|
381
|
+
minLength('yearsEmployed', 3),
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
340
387
|
it('Path<T> still expands on the realistic form type', () => {
|
|
341
388
|
// Path is the most recursion-prone type in forms/ — its union grows with
|
|
342
389
|
// every key at every depth. Probe it with deep representative paths
|
|
@@ -355,6 +402,29 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
355
402
|
>().toEqualTypeOf<boolean | undefined>();
|
|
356
403
|
});
|
|
357
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
|
+
|
|
358
428
|
it('paths through optional sections and nullable lists resolve, at scale', () => {
|
|
359
429
|
// The latent hole this pins: Path admitted these paths all along, but
|
|
360
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
|
-
|
|
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
|
|
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
|
|
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', () => {
|
|
@@ -108,6 +117,59 @@ describe('useFormState', () => {
|
|
|
108
117
|
});
|
|
109
118
|
});
|
|
110
119
|
|
|
120
|
+
test('a validator array runs in order with first-error-wins per field', () => {
|
|
121
|
+
const { result } = renderHook(() =>
|
|
122
|
+
useFormState({
|
|
123
|
+
initialValues,
|
|
124
|
+
constraints: {
|
|
125
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
// Both validators would fail on undefined-adjacent input paths; the
|
|
130
|
+
// FIRST one's message surfaces.
|
|
131
|
+
expect(errorAt(result.current.errors, ['email'])).toBe(
|
|
132
|
+
"'email' cannot be empty",
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
act(() => {
|
|
136
|
+
result.current.onValueChanges((prev) => ({ ...prev, email: 'nope' }));
|
|
137
|
+
});
|
|
138
|
+
expect(errorAt(result.current.errors, ['email'])).toBe(
|
|
139
|
+
"'email' must be a valid email",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
144
|
+
});
|
|
145
|
+
expect(errorAt(result.current.errors, ['email'])).toBeUndefined();
|
|
146
|
+
expect(result.current.isValid).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a valid form with array constraints submits the current values', () => {
|
|
150
|
+
const onSubmit = mock(() => {});
|
|
151
|
+
const { result } = renderHook(() =>
|
|
152
|
+
useFormState({
|
|
153
|
+
initialValues,
|
|
154
|
+
constraints: {
|
|
155
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
156
|
+
},
|
|
157
|
+
onSubmit,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
act(() => {
|
|
162
|
+
result.current.onValueChanges({ email: 'a@b.co', nickname: undefined });
|
|
163
|
+
});
|
|
164
|
+
act(() => {
|
|
165
|
+
result.current.submit();
|
|
166
|
+
});
|
|
167
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
168
|
+
email: 'a@b.co',
|
|
169
|
+
nickname: undefined,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
111
173
|
test('a boolean field can be set, validated, and submitted', () => {
|
|
112
174
|
const onSubmit = mock(() => {});
|
|
113
175
|
const { result } = renderHook(() =>
|
|
@@ -123,7 +185,9 @@ describe('useFormState', () => {
|
|
|
123
185
|
}),
|
|
124
186
|
);
|
|
125
187
|
expect(result.current.values.agreed).toBe(false);
|
|
126
|
-
expect(result.current.errors
|
|
188
|
+
expect(errorAt(result.current.errors, ['agreed'])).toBe(
|
|
189
|
+
'you must agree to the terms',
|
|
190
|
+
);
|
|
127
191
|
expect(result.current.isValid).toBe(false);
|
|
128
192
|
|
|
129
193
|
act(() => {
|
|
@@ -134,7 +198,7 @@ describe('useFormState', () => {
|
|
|
134
198
|
act(() => {
|
|
135
199
|
result.current.onValueChanges((prev) => ({ ...prev, agreed: true }));
|
|
136
200
|
});
|
|
137
|
-
expect(result.current.errors
|
|
201
|
+
expect(errorAt(result.current.errors, ['agreed'])).toBeUndefined();
|
|
138
202
|
expect(result.current.isValid).toBe(true);
|
|
139
203
|
|
|
140
204
|
act(() => {
|
|
@@ -144,6 +208,17 @@ describe('useFormState', () => {
|
|
|
144
208
|
expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co', agreed: true });
|
|
145
209
|
});
|
|
146
210
|
|
|
211
|
+
test('a null constraint entry (possible from untyped JS) is skipped', () => {
|
|
212
|
+
const constraints = {
|
|
213
|
+
email: null,
|
|
214
|
+
} as unknown as { email: (val: string | undefined) => string | null };
|
|
215
|
+
const { result } = renderHook(() =>
|
|
216
|
+
useFormState({ initialValues, constraints }),
|
|
217
|
+
);
|
|
218
|
+
expect(result.current.errors).toEqual([]);
|
|
219
|
+
expect(result.current.isValid).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
147
222
|
test('a failed submit followed by a fix allows the next submit through', () => {
|
|
148
223
|
const onSubmit = mock(() => {});
|
|
149
224
|
const { result } = renderHook(() =>
|
|
@@ -5,12 +5,15 @@ import { createSnapshotStore } from './snapshotStore';
|
|
|
5
5
|
import type { SnapshotStore } from './snapshotStore';
|
|
6
6
|
import type {
|
|
7
7
|
FormDebugSnapshot,
|
|
8
|
-
|
|
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';
|
|
15
|
+
import { validateEntry } from '../validations/walk';
|
|
16
|
+
import type { FlatConstraintEntry } from '../validations/walk';
|
|
14
17
|
|
|
15
18
|
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
16
19
|
// each validator's precise type and Refinement marker survive without any
|
|
@@ -41,20 +44,25 @@ export const useFormState = <
|
|
|
41
44
|
const [values, onValueChanges] = useState<T>(initialValues);
|
|
42
45
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
43
46
|
|
|
44
|
-
const errors:
|
|
47
|
+
const errors: FormError<T>[] = [];
|
|
45
48
|
if (constraints) {
|
|
46
49
|
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
51
|
+
// understand, this assignment is the compile error that says so.
|
|
52
|
+
const entry: FlatConstraintEntry | undefined = constraints[key];
|
|
53
|
+
if (entry == null) continue;
|
|
54
|
+
const failure = validateEntry(entry, values[key], [key]);
|
|
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 });
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
const isValid =
|
|
65
|
+
const isValid = errors.length === 0;
|
|
58
66
|
|
|
59
67
|
// Debugger plumbing: one store + one component per hook instance, created
|
|
60
68
|
// lazily on first render. The component's identity must be stable across
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// `as const satisfies Validations<FormType>` to shape-check against their
|
|
6
6
|
// form type without losing that precision — see src/forms/CLAUDE.md.
|
|
7
7
|
export const perField = <
|
|
8
|
-
const V extends Record<
|
|
8
|
+
const V extends Record<
|
|
9
|
+
string,
|
|
10
|
+
((val: never) => string | null) | readonly ((val: never) => string | null)[]
|
|
11
|
+
>,
|
|
9
12
|
>(
|
|
10
13
|
validations: V,
|
|
11
14
|
): V => validations;
|
|
@@ -97,3 +97,128 @@ describe('Refine<T, V>', () => {
|
|
|
97
97
|
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
describe('Refine<T, V> — validator arrays', () => {
|
|
102
|
+
it('a validator array narrows by the union of its members’ excludes', () => {
|
|
103
|
+
// All members run (first-error-wins only stops on failure, and failure
|
|
104
|
+
// blocks submit), so every member's narrowing is earned — union of
|
|
105
|
+
// excludes over C[number], one Exclude. Same semantics as allOf.
|
|
106
|
+
const constraints = {
|
|
107
|
+
a: [notEmpty('a'), minLength('a', 3), matches('a', /^\S+$/, 'no spaces')],
|
|
108
|
+
} as const satisfies Validations<FormType>;
|
|
109
|
+
|
|
110
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
111
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string>();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('an array of bare validators narrows nothing', () => {
|
|
115
|
+
const constraints = {
|
|
116
|
+
a: [
|
|
117
|
+
(val: string | undefined) => (val ? null : 'required'),
|
|
118
|
+
(val: string | undefined) => (val && val.length > 2 ? null : 'short'),
|
|
119
|
+
],
|
|
120
|
+
} as const satisfies Validations<FormType>;
|
|
121
|
+
|
|
122
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
123
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('bare members ride along beside marked ones without diluting them', () => {
|
|
127
|
+
const constraints = {
|
|
128
|
+
a: [notEmpty('a'), (val: string | undefined) => (val === 'x' ? 'no x' : null)],
|
|
129
|
+
} as const satisfies Validations<FormType>;
|
|
130
|
+
|
|
131
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
132
|
+
// The bare member contributes `never` to the excludes union.
|
|
133
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string>();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('an empty validator array leaves the field unchanged', () => {
|
|
137
|
+
const constraints = { a: [] } as const satisfies Validations<FormType>;
|
|
138
|
+
|
|
139
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
140
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('preserves array-member precision through perField', () => {
|
|
144
|
+
const constraints = perField({
|
|
145
|
+
a: [notEmpty('a'), minLength('a', 3)],
|
|
146
|
+
c: notEmpty('c'),
|
|
147
|
+
}) satisfies Validations<FormType>;
|
|
148
|
+
|
|
149
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
150
|
+
expectTypeOf<Result>().toEqualTypeOf<{
|
|
151
|
+
a: string;
|
|
152
|
+
b: number;
|
|
153
|
+
c: string;
|
|
154
|
+
}>();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('Refine<T, V> — union-typed members inside arrays', () => {
|
|
159
|
+
it('a conditionally-picked member claims only what every branch guarantees', () => {
|
|
160
|
+
// Only ONE branch of the member runs at runtime. minLength narrows
|
|
161
|
+
// nothing, so the member earns (null | undefined | '') & never = never —
|
|
162
|
+
// the field must NOT refine to `string`.
|
|
163
|
+
const cond = Math.random() > 0.5;
|
|
164
|
+
const constraints = {
|
|
165
|
+
a: [cond ? notEmpty('a') : minLength('a', 3)],
|
|
166
|
+
} as const satisfies Validations<FormType>;
|
|
167
|
+
|
|
168
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
169
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string | undefined>();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('earns the intersection of the branches’ excludes, not the union', () => {
|
|
173
|
+
// (undefined | '') & (null | '') = '' — `undefined` must survive because
|
|
174
|
+
// the second branch never rules it out. The unsound union-of-branches
|
|
175
|
+
// reading would have claimed `string`.
|
|
176
|
+
const cond = Math.random() > 0.5;
|
|
177
|
+
const branchA = {} as Validator<string | undefined, undefined | ''>;
|
|
178
|
+
const branchB = {} as Validator<string | undefined, null | ''>;
|
|
179
|
+
const constraints = {
|
|
180
|
+
a: [cond ? branchA : branchB],
|
|
181
|
+
} as const satisfies Validations<FormType>;
|
|
182
|
+
|
|
183
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
184
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string | undefined>();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a union-typed member does not dilute solid members beside it', () => {
|
|
188
|
+
const cond = Math.random() > 0.5;
|
|
189
|
+
const constraints = {
|
|
190
|
+
a: [cond ? notEmpty('a') : minLength('a', 3), notEmpty('a')],
|
|
191
|
+
} as const satisfies Validations<FormType>;
|
|
192
|
+
|
|
193
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
194
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string>();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('allOf: a union-typed part claims only what every branch guarantees', () => {
|
|
198
|
+
const cond = Math.random() > 0.5;
|
|
199
|
+
const constraints = {
|
|
200
|
+
a: allOf(cond ? notEmpty('a') : minLength('a', 3)),
|
|
201
|
+
} as const satisfies Validations<FormType>;
|
|
202
|
+
|
|
203
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
204
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string | undefined>();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('Refine<T, V> — union-typed single constraints (conditional pick)', () => {
|
|
209
|
+
it('narrows a conditionally-picked validator to the union of branch results', () => {
|
|
210
|
+
// Only ONE branch runs at runtime, so the sound result is the UNION of
|
|
211
|
+
// the per-branch refinements — RefineField distributes over the naked
|
|
212
|
+
// constraint union to produce exactly that. (Contrast with arrays above,
|
|
213
|
+
// where every member runs and the excludes union in a single Exclude.)
|
|
214
|
+
// Here the minLength branch never rules out undefined, so undefined
|
|
215
|
+
// must survive in the submit type.
|
|
216
|
+
const cond = Math.random() > 0.5;
|
|
217
|
+
const constraints = {
|
|
218
|
+
a: cond ? notEmpty('a') : minLength('a', 3),
|
|
219
|
+
} as const satisfies Validations<FormType>;
|
|
220
|
+
|
|
221
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
222
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string | undefined>();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -16,23 +16,87 @@ export type Refinement<Excluded = never> = {
|
|
|
16
16
|
export type Validator<Input, Excluded = never> =
|
|
17
17
|
((val: Input) => string | null) & Refinement<Excluded>;
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
19
|
+
// A single field validator. Deliberately does NOT demand the `Refinement`
|
|
20
|
+
// marker: a bare `(val) => string | null` is a legal constraint that narrows
|
|
21
|
+
// nothing. Markers on standard-library validators ride along in the
|
|
22
|
+
// *inferred* type of a concrete constraints object and are recovered
|
|
23
|
+
// structurally by `Refine<>`.
|
|
24
|
+
export type FieldValidator<F> = (val: F) => string | null;
|
|
25
|
+
|
|
26
|
+
// What a constraints key may map to — phase 1 of the target grammar (see
|
|
27
|
+
// plan.md): the leaf forms only. A single validator, or an ordered list of
|
|
28
|
+
// validators run first-error-wins (sugar over `allOf` at the constraint
|
|
29
|
+
// site; `allOf` remains for building reusable composite validators). The
|
|
30
|
+
// structural forms (nested `Validations`, list `each`) arrive in phases 2–3.
|
|
31
|
+
export type FieldConstraint<F> =
|
|
32
|
+
| FieldValidator<F>
|
|
33
|
+
| readonly FieldValidator<F>[];
|
|
34
|
+
|
|
35
|
+
// The constraint for a per-field validation map.
|
|
24
36
|
export type Validations<T extends FormValuesObject> = {
|
|
25
|
-
readonly [K in keyof T]?:
|
|
37
|
+
readonly [K in keyof T]?: FieldConstraint<T[K]>;
|
|
26
38
|
};
|
|
27
39
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
40
|
+
// Extracts a validator's refinement marker; bare functions contribute
|
|
41
|
+
// `never`. Distributes over a union-typed validator, yielding the union of
|
|
42
|
+
// the branches' excludes — which is why it must NOT be applied to array
|
|
43
|
+
// members directly (see `SoundExcludedOf`).
|
|
44
|
+
export type ExcludedOf<C> = C extends Refinement<infer Excluded>
|
|
45
|
+
? Excluded
|
|
46
|
+
: never;
|
|
47
|
+
|
|
48
|
+
type UnionToIntersection<U> = (
|
|
49
|
+
U extends unknown ? (arg: U) => void : never
|
|
50
|
+
) extends (arg: infer I) => void
|
|
51
|
+
? I
|
|
52
|
+
: never;
|
|
53
|
+
|
|
54
|
+
// What one composed validator (an array member, an `allOf` part) is
|
|
55
|
+
// GUARANTEED to exclude. A member that is itself a *union* of validators
|
|
56
|
+
// (`cond ? v1 : v2`) runs only one branch at runtime, so it may only claim
|
|
57
|
+
// the INTERSECTION of its branches' excludes — what every branch guarantees.
|
|
58
|
+
// Each branch's excludes are boxed in a one-tuple before intersecting so a
|
|
59
|
+
// single validator's own union marker (e.g. notEmpty's
|
|
60
|
+
// `null | undefined | ''`) is not collapsed by the intersection; indexing
|
|
61
|
+
// `[0]` afterwards unboxes (the constrained `infer` keeps the index legal
|
|
62
|
+
// for the checker; inferring the element via `[infer E]` would be wrong —
|
|
63
|
+
// inference from an intersection picks one constituent, not the
|
|
64
|
+
// intersection). A bare-function branch contributes `never`, so a union
|
|
65
|
+
// containing one guarantees nothing — its intersection is `never`.
|
|
66
|
+
type SoundExcludedOf<M> = UnionToIntersection<
|
|
67
|
+
M extends unknown ? readonly [ExcludedOf<M>] : never
|
|
68
|
+
> extends infer Boxed extends readonly [unknown]
|
|
69
|
+
? Boxed[0]
|
|
70
|
+
: never;
|
|
71
|
+
|
|
72
|
+
// The excludes earned by a list of validators that ALL run: the union across
|
|
73
|
+
// members, each member contributing only its per-member-sound excludes.
|
|
74
|
+
export type MemberExcludes<C extends readonly unknown[]> = {
|
|
75
|
+
[I in keyof C]: SoundExcludedOf<C[I]>;
|
|
76
|
+
}[number];
|
|
77
|
+
|
|
78
|
+
// One field's refinement. `C` is a naked type parameter, so a *union* of
|
|
79
|
+
// constraint types distributes: `RefineField<F, A | B>` is
|
|
80
|
+
// `RefineField<F, A> | RefineField<F, B>`. That distribution is load-bearing
|
|
81
|
+
// for soundness — a conditionally-picked validator (`cond ? v1 : v2`) only
|
|
82
|
+
// runs ONE branch at runtime, so the field may only narrow to the union of
|
|
83
|
+
// the per-branch results, which is exactly what distribution produces. A
|
|
84
|
+
// validator *array* is the opposite regime: every member runs, so the union
|
|
85
|
+
// ACROSS members is earned in a single `Exclude` — but each member's own
|
|
86
|
+
// contribution is per-member sound (`MemberExcludes`): a union-typed member
|
|
87
|
+
// claims only the intersection of its branches' excludes. Branch order
|
|
88
|
+
// matters once the grammar grows structural forms (validator functions are
|
|
89
|
+
// objects) — `Refinement` stays first.
|
|
90
|
+
type RefineField<F, C> = C extends Refinement<infer Excluded>
|
|
91
|
+
? Exclude<F, Excluded>
|
|
92
|
+
: C extends readonly unknown[]
|
|
93
|
+
? Exclude<F, MemberExcludes<C>>
|
|
94
|
+
: F;
|
|
95
|
+
|
|
96
|
+
// Applies each field's constraint to the form type: the submit-time type.
|
|
97
|
+
// Singles narrow by their marker, arrays by the union of their members'
|
|
98
|
+
// sound excludes; unconstrained fields and bare (marker-less) validators
|
|
31
99
|
// pass through unchanged. Shallow by design — one mapped type, no recursion.
|
|
32
100
|
export type Refine<T extends FormValuesObject, V extends Validations<T>> = {
|
|
33
|
-
[K in keyof T]: K extends keyof V
|
|
34
|
-
? V[K] extends Refinement<infer Excluded>
|
|
35
|
-
? Exclude<T[K], Excluded>
|
|
36
|
-
: T[K]
|
|
37
|
-
: T[K];
|
|
101
|
+
[K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K];
|
|
38
102
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { validateEntry } from './walk';
|
|
3
|
+
|
|
4
|
+
const pass = () => null;
|
|
5
|
+
const fail = (message: string) => () => message;
|
|
6
|
+
|
|
7
|
+
describe('validateEntry — single validator', () => {
|
|
8
|
+
test('a passing validator yields no error', () => {
|
|
9
|
+
expect(validateEntry(pass, 'anything', ['a'])).toBeNull();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('a failing validator yields its message at the given path', () => {
|
|
13
|
+
expect(validateEntry(fail('nope'), 'anything', ['a'])).toEqual({
|
|
14
|
+
path: ['a'],
|
|
15
|
+
error: 'nope',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('the validator receives the field value', () => {
|
|
20
|
+
const spy = mock((val: string) => (val === 'x' ? null : 'expected x'));
|
|
21
|
+
expect(validateEntry(spy, 'x', ['a'])).toBeNull();
|
|
22
|
+
expect(spy).toHaveBeenCalledWith('x');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('validateEntry — validator arrays', () => {
|
|
27
|
+
test('all passing yields no error', () => {
|
|
28
|
+
expect(validateEntry([pass, pass, pass], 'v', ['a'])).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('validators run in array order and the first error wins', () => {
|
|
32
|
+
const result = validateEntry(
|
|
33
|
+
[pass, fail('second'), fail('third')],
|
|
34
|
+
'v',
|
|
35
|
+
['a'],
|
|
36
|
+
);
|
|
37
|
+
expect(result).toEqual({ path: ['a'], error: 'second' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('validators after the first failure are not called', () => {
|
|
41
|
+
const after = mock(() => 'never reached');
|
|
42
|
+
validateEntry([fail('boom'), after], 'v', ['a']);
|
|
43
|
+
expect(after).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('every validator before the failure sees the value', () => {
|
|
47
|
+
const first = mock(() => null);
|
|
48
|
+
const second = mock(() => null);
|
|
49
|
+
expect(validateEntry([first, second], 42, ['n'])).toBeNull();
|
|
50
|
+
expect(first).toHaveBeenCalledWith(42);
|
|
51
|
+
expect(second).toHaveBeenCalledWith(42);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('an empty array passes', () => {
|
|
55
|
+
expect(validateEntry([], undefined, ['a'])).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('validateEntry — path accumulation', () => {
|
|
60
|
+
test('the error carries the path it was given, verbatim', () => {
|
|
61
|
+
// Phase 1 paths are single-key; the signature already speaks PathStep[]
|
|
62
|
+
// so the phases-2/3 {path, error}[] model needs no walk rewrite.
|
|
63
|
+
const result = validateEntry(fail('bad date'), undefined, [
|
|
64
|
+
'drivers',
|
|
65
|
+
3,
|
|
66
|
+
'incidents',
|
|
67
|
+
0,
|
|
68
|
+
'date',
|
|
69
|
+
]);
|
|
70
|
+
expect(result).toEqual({
|
|
71
|
+
path: ['drivers', 3, 'incidents', 0, 'date'],
|
|
72
|
+
error: 'bad date',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PathStep } from '../path/types';
|
|
2
|
+
|
|
3
|
+
// The runtime walk over a constraints object, kept separate from the hook so
|
|
4
|
+
// its semantics are unit-testable without React. Phase 1 grammar is flat —
|
|
5
|
+
// every path has exactly one step — but errors already carry a `PathStep[]`
|
|
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.
|
|
9
|
+
|
|
10
|
+
export type ValidationError = {
|
|
11
|
+
readonly path: readonly PathStep[];
|
|
12
|
+
readonly error: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// The walk's receiving type for one validator. `never` is the one parameter
|
|
16
|
+
// type every field validator is assignable to (contravariance) — the walk
|
|
17
|
+
// cannot correlate a validator's input with its field's value type, so it
|
|
18
|
+
// accepts them all and re-widens at the single call site below.
|
|
19
|
+
type AnyFieldValidator = (val: never) => string | null;
|
|
20
|
+
|
|
21
|
+
// The walk's view of one entry in a `Validations<T>` object. This type is
|
|
22
|
+
// what lets the compiler police the walk's assumptions: the hook assigns
|
|
23
|
+
// `constraints[key]` to it WITHOUT a cast, so when the grammar grows a form
|
|
24
|
+
// that is neither a function nor an array of them (nested spec, list `each`),
|
|
25
|
+
// that assignment stops compiling and the walk must learn the new form —
|
|
26
|
+
// instead of a stale walk misinterpreting it at runtime.
|
|
27
|
+
export type FlatConstraintEntry =
|
|
28
|
+
| AnyFieldValidator
|
|
29
|
+
| readonly AnyFieldValidator[];
|
|
30
|
+
|
|
31
|
+
// Runs one constraint entry against the field value found at `path`.
|
|
32
|
+
// Semantics for arrays (identical to `allOf`): validators run in array
|
|
33
|
+
// order, first error wins — later validators are not called once one fails.
|
|
34
|
+
// An empty array passes, mirroring its refinement (`Exclude<F, never>`).
|
|
35
|
+
export const validateEntry = (
|
|
36
|
+
entry: FlatConstraintEntry,
|
|
37
|
+
value: unknown,
|
|
38
|
+
path: readonly PathStep[],
|
|
39
|
+
): ValidationError | null => {
|
|
40
|
+
const validators = typeof entry === 'function' ? [entry] : entry;
|
|
41
|
+
|
|
42
|
+
for (const validator of validators) {
|
|
43
|
+
// Safe: the constraints object was type-checked against the form type
|
|
44
|
+
// where it was built, so this validator accepts the value at `path`; the
|
|
45
|
+
// walk just can't see that correlation. Same honest contravariant
|
|
46
|
+
// widening as `allOf`'s part-call.
|
|
47
|
+
const error = (validator as (val: unknown) => string | null)(value);
|
|
48
|
+
if (error != null) return { path, error };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Refinement, Validator } from '../validations/types';
|
|
1
|
+
import type { MemberExcludes, Refinement, Validator } from '../validations/types';
|
|
2
2
|
|
|
3
3
|
// Standard-library validators. Each one declares its refinement explicitly —
|
|
4
4
|
// `Validator<Input, Excluded>` — even when it narrows nothing (`never`).
|
|
@@ -60,8 +60,6 @@ export const min = (
|
|
|
60
60
|
{} as Refinement,
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
-
type ExcludedOf<V> = V extends Refinement<infer Excluded> ? Excluded : never;
|
|
64
|
-
|
|
65
63
|
// The composed input is the *intersection* of the parts' inputs: a value the
|
|
66
64
|
// composite accepts must be acceptable to every part. Inferring `I` from the
|
|
67
65
|
// union of function types puts it in contravariant position, which is what
|
|
@@ -75,13 +73,15 @@ type InputOf<Validators extends readonly ((val: never) => string | null)[]> =
|
|
|
75
73
|
|
|
76
74
|
// Composes validators on one field: first error wins. The refinement is the
|
|
77
75
|
// union of the parts' refinements — running all of them earns all of their
|
|
78
|
-
// narrowings
|
|
79
|
-
// `
|
|
76
|
+
// narrowings, with each part contributing only its per-member-sound excludes
|
|
77
|
+
// (`MemberExcludes`: a union-typed part earns just the intersection of its
|
|
78
|
+
// branches). `const Validators` keeps each member's precise type so the
|
|
79
|
+
// markers survive extraction; bare functions contribute `never`.
|
|
80
80
|
export const allOf = <
|
|
81
81
|
const Validators extends readonly ((val: never) => string | null)[],
|
|
82
82
|
>(
|
|
83
83
|
...validators: Validators
|
|
84
|
-
): Validator<InputOf<Validators>,
|
|
84
|
+
): Validator<InputOf<Validators>, MemberExcludes<Validators>> =>
|
|
85
85
|
Object.assign(
|
|
86
86
|
(val: InputOf<Validators>) => {
|
|
87
87
|
for (const validator of validators) {
|
|
@@ -91,5 +91,5 @@ export const allOf = <
|
|
|
91
91
|
}
|
|
92
92
|
return null;
|
|
93
93
|
},
|
|
94
|
-
{} as Refinement<
|
|
94
|
+
{} as Refinement<MemberExcludes<Validators>>,
|
|
95
95
|
);
|