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