@structuralists/scaffolding 0.4.2 → 0.5.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 +41 -18
- package/src/forms/plan.md +270 -0
- package/src/forms/useFormState/types.ts +10 -0
- package/src/forms/useFormState/useFormState.stories.tsx +196 -0
- package/src/forms/useFormState/useFormState.test-d.ts +209 -0
- package/src/forms/useFormState/useFormState.test.tsx +134 -0
- package/src/forms/useFormState/useFormState.ts +41 -5
- package/src/forms/validations/perField.ts +11 -0
- package/src/forms/validations/types.test-d.ts +74 -1
- package/src/forms/validations/types.ts +23 -0
- package/src/forms/validators/validators.test.ts +99 -0
- package/src/forms/validators/validators.ts +95 -0
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -35,24 +35,36 @@ function composition.
|
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
37
|
const constraints = perField({
|
|
38
|
-
a: (val: string | undefined) => !val ? "'a' cannot be empty" : null,
|
|
39
|
-
})
|
|
38
|
+
a: (val: string | undefined) => (!val ? "'a' cannot be empty" : null),
|
|
39
|
+
}) satisfies Validations<FormType>;
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
`perField` is the entry point that produces a `Validations<FormType>`-shaped
|
|
43
43
|
value while preserving the precise types of each individual validator.
|
|
44
44
|
|
|
45
|
-
###
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
### The precision-preserving ceremony, by call-site shape
|
|
46
|
+
|
|
47
|
+
The enemy is widening: if the constraints object's inferred type loses each
|
|
48
|
+
validator's specific function type, the refinement markers are lost and
|
|
49
|
+
`Refine<>` has nothing to walk. What prevents widening depends on where the
|
|
50
|
+
constraints object is written:
|
|
51
|
+
|
|
52
|
+
- **Inline in the `useFormState` call — no ceremony at all.** The hook's
|
|
53
|
+
`const V extends Validations<T>` type parameter freezes the literal type,
|
|
54
|
+
and the generic bound shape-checks against the form type. This is the
|
|
55
|
+
everyday path.
|
|
56
|
+
- **Pre-built via `perField` — add `satisfies Validations<FormType>`.**
|
|
57
|
+
`perField`'s own `const` type parameter plays the freezing role (`as const`
|
|
58
|
+
cannot be applied to a call expression — TS1355), and `satisfies` checks
|
|
59
|
+
shape against `FormType` (every key valid, every validator's input
|
|
60
|
+
compatible) without widening the way an annotation
|
|
61
|
+
`: Validations<FormType>` would.
|
|
62
|
+
- **Pre-built as a bare object literal — `as const satisfies
|
|
63
|
+
Validations<FormType>`,** both parts required: `as const` freezes,
|
|
64
|
+
`satisfies` checks.
|
|
65
|
+
|
|
66
|
+
Never *annotate* a constraints binding (`: Validations<FormType>`) — that is
|
|
67
|
+
the widening failure mode all three patterns exist to avoid.
|
|
56
68
|
|
|
57
69
|
## Standard-library validators carry refinements
|
|
58
70
|
|
|
@@ -134,11 +146,22 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
134
146
|
## Layout
|
|
135
147
|
|
|
136
148
|
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
137
|
-
`FormValuesObject`, `FormValueList`).
|
|
149
|
+
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
150
|
+
onValueChanges, errors, isValid, submitAttempted, submit }`; `errors` is
|
|
151
|
+
live-derived from current values each render (validators are pure and
|
|
152
|
+
cheap), `submitAttempted` lets UIs gate error display, and `submit()`
|
|
153
|
+
performs the one honest cast to `Refine<T, V>` — earned because the
|
|
154
|
+
validators just passed at runtime.
|
|
138
155
|
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
139
156
|
Will be used for granular setters and for surfacing per-field
|
|
140
157
|
errors/touched state. Coupled to the form value model intentionally.
|
|
141
|
-
-
|
|
142
|
-
`Refinement<>` infra.
|
|
143
|
-
|
|
144
|
-
|
|
158
|
+
- `validations/` — `perField`, `Validations<T>`, `Refine<T,V>`,
|
|
159
|
+
`Refinement<>` infra. `Validations<T>` accepts bare
|
|
160
|
+
`(val) => string | null` functions too — they simply narrow nothing.
|
|
161
|
+
- `validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
162
|
+
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
163
|
+
carries the union of the parts' refinements. `allOf` derives its input type
|
|
164
|
+
as the intersection of the parts' inputs from the const tuple (not a
|
|
165
|
+
standalone inferred type parameter — that breaks without an outer
|
|
166
|
+
contextual type, e.g. inside inline constraints). `oneOf` is deferred:
|
|
167
|
+
exclusion-only markers can't express keep-only narrowing yet.
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFormState } from './useFormState';
|
|
4
|
+
import { allOf, matches, notEmpty } from '../validators/validators';
|
|
5
|
+
|
|
6
|
+
type SignupForm = {
|
|
7
|
+
email: string | undefined;
|
|
8
|
+
nickname: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const initialValues: SignupForm = { email: undefined, nickname: undefined };
|
|
12
|
+
|
|
13
|
+
describe('useFormState', () => {
|
|
14
|
+
test('exposes initial values', () => {
|
|
15
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
16
|
+
expect(result.current.values).toEqual(initialValues);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('onValueChanges accepts a replacement object', () => {
|
|
20
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
21
|
+
act(() => {
|
|
22
|
+
result.current.onValueChanges({ email: 'a@b.co', nickname: 'will' });
|
|
23
|
+
});
|
|
24
|
+
expect(result.current.values.email).toBe('a@b.co');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('onValueChanges accepts an updater function', () => {
|
|
28
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
29
|
+
act(() => {
|
|
30
|
+
result.current.onValueChanges((prev) => ({ ...prev, nickname: 'will' }));
|
|
31
|
+
});
|
|
32
|
+
expect(result.current.values).toEqual({ email: undefined, nickname: 'will' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('errors are derived live from the current values', () => {
|
|
36
|
+
const { result } = renderHook(() =>
|
|
37
|
+
useFormState({
|
|
38
|
+
initialValues,
|
|
39
|
+
constraints: { email: notEmpty('email') },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
expect(result.current.errors.email).toBe("'email' cannot be empty");
|
|
43
|
+
expect(result.current.isValid).toBe(false);
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
47
|
+
});
|
|
48
|
+
expect(result.current.errors.email).toBeUndefined();
|
|
49
|
+
expect(result.current.isValid).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('fields without constraints never produce errors', () => {
|
|
53
|
+
const { result } = renderHook(() =>
|
|
54
|
+
useFormState({
|
|
55
|
+
initialValues,
|
|
56
|
+
constraints: { email: notEmpty('email') },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
expect(result.current.errors.nickname).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a form without constraints is always valid', () => {
|
|
63
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
64
|
+
expect(result.current.isValid).toBe(true);
|
|
65
|
+
expect(result.current.errors).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('submit on an invalid form marks the attempt and skips onSubmit', () => {
|
|
69
|
+
const onSubmit = mock(() => {});
|
|
70
|
+
const { result } = renderHook(() =>
|
|
71
|
+
useFormState({
|
|
72
|
+
initialValues,
|
|
73
|
+
constraints: { email: notEmpty('email') },
|
|
74
|
+
onSubmit,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(result.current.submitAttempted).toBe(false);
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current.submit();
|
|
81
|
+
});
|
|
82
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
83
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('submit on a valid form calls onSubmit with the current values', () => {
|
|
87
|
+
const onSubmit = mock(() => {});
|
|
88
|
+
const { result } = renderHook(() =>
|
|
89
|
+
useFormState({
|
|
90
|
+
initialValues,
|
|
91
|
+
constraints: {
|
|
92
|
+
email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
|
|
93
|
+
},
|
|
94
|
+
onSubmit,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
result.current.onValueChanges({ email: 'a@b.co', nickname: undefined });
|
|
100
|
+
});
|
|
101
|
+
act(() => {
|
|
102
|
+
result.current.submit();
|
|
103
|
+
});
|
|
104
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
106
|
+
email: 'a@b.co',
|
|
107
|
+
nickname: undefined,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a failed submit followed by a fix allows the next submit through', () => {
|
|
112
|
+
const onSubmit = mock(() => {});
|
|
113
|
+
const { result } = renderHook(() =>
|
|
114
|
+
useFormState({
|
|
115
|
+
initialValues,
|
|
116
|
+
constraints: { email: notEmpty('email') },
|
|
117
|
+
onSubmit,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
result.current.submit();
|
|
123
|
+
});
|
|
124
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
128
|
+
});
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.submit();
|
|
131
|
+
});
|
|
132
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import type { FormHelpers, FormValuesObject } from './types';
|
|
2
|
+
import type { FormErrors, FormHelpers, FormValuesObject } from './types';
|
|
3
|
+
import type { Refine, Validations } from '../validations/types';
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
+
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
6
|
+
// each validator's precise type and Refinement marker survive without any
|
|
7
|
+
// `as const` at the call site. Constraint objects built outside the call
|
|
8
|
+
// still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
|
|
9
|
+
type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
5
10
|
initialValues: T;
|
|
11
|
+
constraints?: V;
|
|
12
|
+
onSubmit?: (values: Refine<T, V>) => void;
|
|
6
13
|
};
|
|
7
14
|
|
|
8
|
-
export const useFormState = <
|
|
9
|
-
|
|
15
|
+
export const useFormState = <
|
|
16
|
+
T extends FormValuesObject,
|
|
17
|
+
const V extends Validations<T> = Validations<T>,
|
|
18
|
+
>(
|
|
19
|
+
args: Args<T, V>,
|
|
20
|
+
): FormHelpers<T> => {
|
|
21
|
+
const { initialValues, constraints, onSubmit } = args;
|
|
10
22
|
|
|
11
23
|
const [values, onValueChanges] = useState<T>(initialValues);
|
|
24
|
+
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
const errors: FormErrors<T> = {};
|
|
27
|
+
if (constraints) {
|
|
28
|
+
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
29
|
+
// Field type and validator input are correlated per key, but TS can't
|
|
30
|
+
// track that through the union of keys — widen the input to unknown.
|
|
31
|
+
const validator = constraints[key] as
|
|
32
|
+
| ((val: unknown) => string | null)
|
|
33
|
+
| undefined;
|
|
34
|
+
const error = validator?.(values[key]);
|
|
35
|
+
if (error != null) errors[key] = error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isValid = Object.keys(errors).length === 0;
|
|
40
|
+
|
|
41
|
+
const submit = () => {
|
|
42
|
+
setSubmitAttempted(true);
|
|
43
|
+
if (!isValid) return;
|
|
44
|
+
// Every constrained field's validator just passed at runtime — exactly
|
|
45
|
+
// the guarantee Refine<T, V> encodes.
|
|
46
|
+
onSubmit?.(values as Refine<T, V>);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { values, onValueChanges, errors, isValid, submitAttempted, submit };
|
|
14
50
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Aggregation entry point for building a `Validations<T>`-shaped constraints
|
|
2
|
+
// object. Runtime identity; the value is in the type: the `const` type
|
|
3
|
+
// parameter freezes each validator's precise type (including `Refinement`
|
|
4
|
+
// markers) instead of letting function types widen. Callers still apply
|
|
5
|
+
// `as const satisfies Validations<FormType>` to shape-check against their
|
|
6
|
+
// form type without losing that precision — see src/forms/CLAUDE.md.
|
|
7
|
+
export const perField = <
|
|
8
|
+
const V extends Record<string, (val: never) => string | null>,
|
|
9
|
+
>(
|
|
10
|
+
validations: V,
|
|
11
|
+
): V => validations;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
-
import
|
|
2
|
+
import { perField } from './perField';
|
|
3
|
+
import { allOf, matches, minLength, notEmpty } from '../validators/validators';
|
|
4
|
+
import type { Refine, Refinement, Validations, Validator } from './types';
|
|
3
5
|
|
|
4
6
|
describe('Validator<Input, Excluded>', () => {
|
|
5
7
|
it('preserves the input type as the call parameter', () => {
|
|
@@ -24,3 +26,74 @@ describe('Validator<Input, Excluded>', () => {
|
|
|
24
26
|
expectTypeOf<Extracted>().toBeNever();
|
|
25
27
|
});
|
|
26
28
|
});
|
|
29
|
+
|
|
30
|
+
type FormType = {
|
|
31
|
+
a: string | undefined;
|
|
32
|
+
b: number;
|
|
33
|
+
c: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('Refine<T, V>', () => {
|
|
37
|
+
it('narrows fields whose validator carries a refinement', () => {
|
|
38
|
+
const constraints = {
|
|
39
|
+
a: notEmpty('a'),
|
|
40
|
+
} as const satisfies Validations<FormType>;
|
|
41
|
+
|
|
42
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
43
|
+
expectTypeOf<Result>().toEqualTypeOf<{
|
|
44
|
+
a: string;
|
|
45
|
+
b: number;
|
|
46
|
+
c: string | null;
|
|
47
|
+
}>();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('preserves precision through perField without as const', () => {
|
|
51
|
+
const constraints = perField({
|
|
52
|
+
a: notEmpty('a'),
|
|
53
|
+
c: notEmpty('c'),
|
|
54
|
+
}) satisfies Validations<FormType>;
|
|
55
|
+
|
|
56
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
57
|
+
expectTypeOf<Result>().toEqualTypeOf<{
|
|
58
|
+
a: string;
|
|
59
|
+
b: number;
|
|
60
|
+
c: string;
|
|
61
|
+
}>();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('bare validator functions narrow nothing', () => {
|
|
65
|
+
const constraints = {
|
|
66
|
+
a: (val: string | undefined) => (val ? null : 'required'),
|
|
67
|
+
} as const satisfies Validations<FormType>;
|
|
68
|
+
|
|
69
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
70
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a constraints object widened by annotation refines nothing', () => {
|
|
74
|
+
// Annotating with `: Validations<FormType>` (instead of `satisfies`)
|
|
75
|
+
// widens every validator to a bare function — markers are lost. This is
|
|
76
|
+
// the failure mode the `as const satisfies` / const-generic patterns exist
|
|
77
|
+
// to prevent.
|
|
78
|
+
const constraints: Validations<FormType> = { a: notEmpty('a') };
|
|
79
|
+
|
|
80
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
81
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('allOf carries the union of its parts’ refinements', () => {
|
|
85
|
+
const constraints = {
|
|
86
|
+
a: allOf(notEmpty('a'), minLength('a', 3), matches('a', /^\S+$/, 'no spaces')),
|
|
87
|
+
} as const satisfies Validations<FormType>;
|
|
88
|
+
|
|
89
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
90
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string>();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('an empty constraints object leaves the form type unchanged', () => {
|
|
94
|
+
const constraints = {} as const satisfies Validations<FormType>;
|
|
95
|
+
|
|
96
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
97
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FormValuesObject } from '../useFormState/types';
|
|
2
|
+
|
|
1
3
|
// Phantom marker carried by validators. The runtime value of `__excludes`
|
|
2
4
|
// is meaningless and never read — only its declared type matters. Required
|
|
3
5
|
// (not optional) so validators can't accidentally drop the marker, and so
|
|
@@ -13,3 +15,24 @@ export type Refinement<Excluded = never> = {
|
|
|
13
15
|
// validates at runtime; it just doesn't shrink the field's submit type).
|
|
14
16
|
export type Validator<Input, Excluded = never> =
|
|
15
17
|
((val: Input) => string | null) & Refinement<Excluded>;
|
|
18
|
+
|
|
19
|
+
// The constraint for a per-field validation map. Deliberately does NOT demand
|
|
20
|
+
// the `Refinement` marker: a bare `(val) => string | null` is a legal
|
|
21
|
+
// constraint that narrows nothing. Markers on standard-library validators
|
|
22
|
+
// ride along in the *inferred* type of a concrete constraints object and are
|
|
23
|
+
// recovered structurally by `Refine<>`.
|
|
24
|
+
export type Validations<T extends FormValuesObject> = {
|
|
25
|
+
readonly [K in keyof T]?: (val: T[K]) => string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Applies each field's validator marker to the form type: the submit-time
|
|
29
|
+
// type. Fields whose validator carries `Refinement<X>` become
|
|
30
|
+
// `Exclude<T[K], X>`; unconstrained fields and bare (marker-less) validators
|
|
31
|
+
// pass through unchanged. Shallow by design — one mapped type, no recursion.
|
|
32
|
+
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];
|
|
38
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { allOf, matches, min, minLength, notEmpty } from './validators';
|
|
3
|
+
|
|
4
|
+
describe('notEmpty', () => {
|
|
5
|
+
const validator = notEmpty('email');
|
|
6
|
+
|
|
7
|
+
test('rejects null, undefined, and the empty string', () => {
|
|
8
|
+
expect(validator(null)).toBe("'email' cannot be empty");
|
|
9
|
+
expect(validator(undefined)).toBe("'email' cannot be empty");
|
|
10
|
+
expect(validator('')).toBe("'email' cannot be empty");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('passes any other value', () => {
|
|
14
|
+
expect(validator('a@b.co')).toBeNull();
|
|
15
|
+
expect(validator(0)).toBeNull();
|
|
16
|
+
expect(validator(' ')).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('minLength', () => {
|
|
21
|
+
const validator = minLength('nickname', 3);
|
|
22
|
+
|
|
23
|
+
test('rejects strings shorter than the minimum', () => {
|
|
24
|
+
expect(validator('ab')).toBe("'nickname' must be at least 3 characters");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('passes strings at or above the minimum', () => {
|
|
28
|
+
expect(validator('abc')).toBeNull();
|
|
29
|
+
expect(validator('abcd')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('passes nullish and empty values — requiredness is notEmpty’s job', () => {
|
|
33
|
+
expect(validator(null)).toBeNull();
|
|
34
|
+
expect(validator(undefined)).toBeNull();
|
|
35
|
+
expect(validator('')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('matches', () => {
|
|
40
|
+
const validator = matches('email', /@/, 'a valid email');
|
|
41
|
+
|
|
42
|
+
test('rejects values that fail the pattern', () => {
|
|
43
|
+
expect(validator('not-an-email')).toBe("'email' must be a valid email");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('passes values matching the pattern', () => {
|
|
47
|
+
expect(validator('a@b.co')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('passes nullish and empty values', () => {
|
|
51
|
+
expect(validator(null)).toBeNull();
|
|
52
|
+
expect(validator(undefined)).toBeNull();
|
|
53
|
+
expect(validator('')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('min', () => {
|
|
58
|
+
const validator = min('age', 18);
|
|
59
|
+
|
|
60
|
+
test('rejects numbers below the minimum', () => {
|
|
61
|
+
expect(validator(17)).toBe("'age' must be at least 18");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('passes numbers at or above the minimum', () => {
|
|
65
|
+
expect(validator(18)).toBeNull();
|
|
66
|
+
expect(validator(99)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('passes nullish values', () => {
|
|
70
|
+
expect(validator(null)).toBeNull();
|
|
71
|
+
expect(validator(undefined)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('allOf', () => {
|
|
76
|
+
const validator = allOf(
|
|
77
|
+
notEmpty('email'),
|
|
78
|
+
minLength('email', 6),
|
|
79
|
+
matches('email', /@/, 'a valid email'),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
test('returns the first failing part’s error', () => {
|
|
83
|
+
expect(validator(undefined)).toBe("'email' cannot be empty");
|
|
84
|
+
expect(validator('a@b')).toBe("'email' must be at least 6 characters");
|
|
85
|
+
expect(validator('abcdefg')).toBe("'email' must be a valid email");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('passes when every part passes', () => {
|
|
89
|
+
expect(validator('a@b.com')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('accepts bare validator functions alongside marked ones', () => {
|
|
93
|
+
const noAdmin = (val: string | null | undefined) =>
|
|
94
|
+
val === 'admin' ? 'reserved' : null;
|
|
95
|
+
const composed = allOf(notEmpty('name'), noAdmin);
|
|
96
|
+
expect(composed('admin')).toBe('reserved');
|
|
97
|
+
expect(composed('will')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Refinement, Validator } from '../validations/types';
|
|
2
|
+
|
|
3
|
+
// Standard-library validators. Each one declares its refinement explicitly —
|
|
4
|
+
// `Validator<Input, Excluded>` — even when it narrows nothing (`never`).
|
|
5
|
+
// The marker is attached with `Object.assign(fn, {} as Refinement<X>)`: a
|
|
6
|
+
// pure type-level tag, never read at runtime.
|
|
7
|
+
|
|
8
|
+
// Rules out null, undefined, and the empty string. The only refining
|
|
9
|
+
// validator in the baseline set: submit-time type becomes
|
|
10
|
+
// `Exclude<FieldType, null | undefined | ''>`.
|
|
11
|
+
export const notEmpty = (
|
|
12
|
+
fieldName: string,
|
|
13
|
+
): Validator<unknown, null | undefined | ''> =>
|
|
14
|
+
Object.assign(
|
|
15
|
+
(val: unknown) =>
|
|
16
|
+
val == null || val === '' ? `'${fieldName}' cannot be empty` : null,
|
|
17
|
+
{} as Refinement<null | undefined | ''>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Minimum string length. Nullish and empty values pass — requiredness is
|
|
21
|
+
// notEmpty's job; compose via allOf. Narrows nothing: TypeScript has no type
|
|
22
|
+
// for "string of at least n characters".
|
|
23
|
+
export const minLength = (
|
|
24
|
+
fieldName: string,
|
|
25
|
+
min: number,
|
|
26
|
+
): Validator<string | null | undefined> =>
|
|
27
|
+
Object.assign(
|
|
28
|
+
(val: string | null | undefined) =>
|
|
29
|
+
val != null && val !== '' && val.length < min
|
|
30
|
+
? `'${fieldName}' must be at least ${min} characters`
|
|
31
|
+
: null,
|
|
32
|
+
{} as Refinement,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Regex test. Nullish and empty values pass (compose with notEmpty for
|
|
36
|
+
// requiredness). Narrows nothing.
|
|
37
|
+
export const matches = (
|
|
38
|
+
fieldName: string,
|
|
39
|
+
pattern: RegExp,
|
|
40
|
+
description: string,
|
|
41
|
+
): Validator<string | null | undefined> =>
|
|
42
|
+
Object.assign(
|
|
43
|
+
(val: string | null | undefined) =>
|
|
44
|
+
val != null && val !== '' && !pattern.test(val)
|
|
45
|
+
? `'${fieldName}' must be ${description}`
|
|
46
|
+
: null,
|
|
47
|
+
{} as Refinement,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Minimum numeric value. Nullish passes. Narrows nothing.
|
|
51
|
+
export const min = (
|
|
52
|
+
fieldName: string,
|
|
53
|
+
minimum: number,
|
|
54
|
+
): Validator<number | null | undefined> =>
|
|
55
|
+
Object.assign(
|
|
56
|
+
(val: number | null | undefined) =>
|
|
57
|
+
val != null && val < minimum
|
|
58
|
+
? `'${fieldName}' must be at least ${minimum}`
|
|
59
|
+
: null,
|
|
60
|
+
{} as Refinement,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
type ExcludedOf<V> = V extends Refinement<infer Excluded> ? Excluded : never;
|
|
64
|
+
|
|
65
|
+
// The composed input is the *intersection* of the parts' inputs: a value the
|
|
66
|
+
// composite accepts must be acceptable to every part. Inferring `I` from the
|
|
67
|
+
// union of function types puts it in contravariant position, which is what
|
|
68
|
+
// makes TS produce the intersection. Computing it this way — rather than as
|
|
69
|
+
// a standalone `Input` type parameter inferred from the arguments — matters:
|
|
70
|
+
// a separate parameter resolves from whichever argument TS visits first
|
|
71
|
+
// (e.g. `unknown` from notEmpty) and then rejects narrower siblings when the
|
|
72
|
+
// call has no outer contextual type, as inside an inline constraints object.
|
|
73
|
+
type InputOf<Validators extends readonly ((val: never) => string | null)[]> =
|
|
74
|
+
Validators[number] extends (val: infer I) => string | null ? I : never;
|
|
75
|
+
|
|
76
|
+
// Composes validators on one field: first error wins. The refinement is the
|
|
77
|
+
// union of the parts' refinements — running all of them earns all of their
|
|
78
|
+
// narrowings. `const Validators` keeps each member's precise type so
|
|
79
|
+
// `ExcludedOf` can extract markers; bare functions contribute `never`.
|
|
80
|
+
export const allOf = <
|
|
81
|
+
const Validators extends readonly ((val: never) => string | null)[],
|
|
82
|
+
>(
|
|
83
|
+
...validators: Validators
|
|
84
|
+
): Validator<InputOf<Validators>, ExcludedOf<Validators[number]>> =>
|
|
85
|
+
Object.assign(
|
|
86
|
+
(val: InputOf<Validators>) => {
|
|
87
|
+
for (const validator of validators) {
|
|
88
|
+
// Safe: each part's declared input is a supertype of the intersection.
|
|
89
|
+
const error = (validator as (v: unknown) => string | null)(val);
|
|
90
|
+
if (error != null) return error;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
},
|
|
94
|
+
{} as Refinement<ExcludedOf<Validators[number]>>,
|
|
95
|
+
);
|