@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -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
- }) as const satisfies Validations<FormType>;
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
- ### Why `as const satisfies` (both required)
46
-
47
- - **`as const`** freezes the object's literal type including each
48
- validator's specific function type and any refinement markers attached
49
- to it. Without `as const`, function types widen, markers are lost,
50
- `Refine<>` has nothing to walk.
51
- - **`satisfies Validations<FormType>`** checks shape against `FormType`
52
- (every key valid, every validator's input compatible) without widening
53
- the inferred type the way an annotation `: Validations<FormType>` would.
54
-
55
- The combination = "type-check this shape, but don't lose any precision."
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
- - (planned) `validations/` — `perField`, `Validations<T>`, `Refine<T,V>`,
142
- `Refinement<>` infra.
143
- - (planned) `validators/`the standard-library validators
144
- (`notEmpty`, `oneOf`, `matches`, …) each carrying its own refinement.
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 Args<T extends FormValuesObject> = {
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 = <T extends FormValuesObject>(args: Args<T>): FormHelpers<T> => {
9
- const { initialValues } = args;
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
- return { values, onValueChanges };
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 type { Refinement, Validator } from './types';
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
+ );