@structuralists/scaffolding 0.7.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.
@@ -10,6 +10,72 @@ export type FormValueList = FormValuesObject[];
10
10
 
11
11
  export type FormValue = FormValuesObject | FormValueList | FormValueSimple;
12
12
 
13
+ // --- Union policy ---------------------------------------------------------
14
+ // After stripping null/undefined, a form value must be exactly ONE shape: a
15
+ // simple value (scalar unions like `'a' | 'b'` count as simple), a single
16
+ // object type, or a single list type. Everything else — object|object
17
+ // (tagged or not), object|list, object|scalar, list|list — is disallowed in
18
+ // form state, because the path machinery (`Path`/`ValueAt`) cannot resolve
19
+ // through it reliably. See "Union policy" in src/forms/CLAUDE.md.
20
+
21
+ type UnionToIntersection<U> = (
22
+ U extends unknown ? (x: U) => void : never
23
+ ) extends (x: infer I) => void
24
+ ? I
25
+ : never;
26
+
27
+ type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;
28
+
29
+ // Callers strip null/undefined before asking (nullability is the one union
30
+ // that IS allowed); the FormValueSimple check must come first so scalar
31
+ // unions — including `boolean`, itself `true | false` — never reach IsUnion.
32
+ export type IsDisallowedFormUnion<T> = [T] extends [FormValueSimple]
33
+ ? false
34
+ : IsUnion<T>;
35
+
36
+ // Loud rejection marker: the resolved type when a hand-written ValueAt
37
+ // steps into a disallowed union (Path<T> never admits such paths, so this
38
+ // only surfaces from raw ValueAt uses). Deliberately not an alias of the
39
+ // field type so nothing real is assignable to it, and never a quiet `never`.
40
+ export type DisallowedFormUnion<F> = {
41
+ readonly 'ERROR: form state disallows unions of objects/lists — restructure this field': F;
42
+ };
43
+
44
+ // Does this field's subtree contain a disallowed union anywhere? The check
45
+ // FormValueSimple first: string[]/Set<string> are simple leaves, not lists
46
+ // to recurse into.
47
+ type FieldHasDisallowedUnion<F> = [NonNullable<F>] extends [FormValueSimple]
48
+ ? false
49
+ : IsDisallowedFormUnion<NonNullable<F>> extends true
50
+ ? true
51
+ : [NonNullable<F>] extends [Array<infer E>]
52
+ ? FieldHasDisallowedUnion<E>
53
+ : [NonNullable<F>] extends [FormValuesObject]
54
+ ? HasDisallowedUnion<NonNullable<F>>
55
+ : false;
56
+
57
+ type HasDisallowedUnion<T> = true extends {
58
+ [K in keyof T]: FieldHasDisallowedUnion<T[K]>;
59
+ }[keyof T]
60
+ ? true
61
+ : false;
62
+
63
+ // Top-level keys whose subtree violates the policy — shown in the boundary
64
+ // error so the offender is named, not just detected.
65
+ type DisallowedUnionKeys<T> = {
66
+ [K in keyof T]: FieldHasDisallowedUnion<T[K]> extends true ? K : never;
67
+ }[keyof T];
68
+
69
+ // The useFormState boundary gate. Intersected with the hook's Args: a legal
70
+ // form type contributes `unknown` (a no-op); an illegal one demands an
71
+ // unsatisfiable property whose name states the policy and whose type names
72
+ // the offending keys. The offending-keys walk only runs on failing calls.
73
+ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
74
+ ? {
75
+ 'ERROR: form state disallows unions of objects/lists — see the union policy in src/forms/CLAUDE.md': DisallowedUnionKeys<T>;
76
+ }
77
+ : unknown;
78
+
13
79
  export type FormErrors<T extends FormValuesObject> = Partial<
14
80
  Record<keyof T, string>
15
81
  >;
@@ -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 },
@@ -133,6 +145,98 @@ describe('useFormState onSubmit narrowing — pre-built constraints', () => {
133
145
  });
134
146
  });
135
147
 
148
+ // ---------------------------------------------------------------------------
149
+ // Union policy at the hook boundary (see "Union policy" in src/forms/CLAUDE.md):
150
+ // nullability is the one union form state supports on object/list fields.
151
+ // Everything else — tagged or untagged object unions, object|list,
152
+ // object|scalar — is rejected at the useFormState call, naming the offending
153
+ // keys, instead of silently producing dead types in resolved paths later.
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe('useFormState union policy at the boundary', () => {
157
+ it('accepts optional sections and nullable lists', () => {
158
+ useFormState({
159
+ initialValues: {
160
+ home: undefined as { city: string | undefined } | undefined,
161
+ entries: null as Array<{ title: string }> | null,
162
+ },
163
+ onSubmit: (values) => {
164
+ expectTypeOf(values.home).toEqualTypeOf<
165
+ { city: string | undefined } | undefined
166
+ >();
167
+ },
168
+ });
169
+ });
170
+
171
+ it('rejects tagged object unions', () => {
172
+ type Party =
173
+ | { kind: 'person'; name: string }
174
+ | { kind: 'company'; vat: string };
175
+
176
+ // @ts-expect-error tagged object unions are disallowed in form state
177
+ useFormState({
178
+ initialValues: { party: { kind: 'person', name: 'Ada' } as Party },
179
+ });
180
+ });
181
+
182
+ it('rejects untagged object unions, object|list, and object|scalar', () => {
183
+ // @ts-expect-error untagged object unions are disallowed in form state
184
+ useFormState({
185
+ initialValues: {
186
+ thing: { a: '' } as { a: string } | { b: number },
187
+ },
188
+ });
189
+
190
+ // @ts-expect-error object|list unions are disallowed in form state
191
+ useFormState({
192
+ initialValues: {
193
+ x: [] as Array<{ a: string }> | { a: string },
194
+ },
195
+ });
196
+
197
+ // @ts-expect-error object|scalar unions are disallowed in form state
198
+ useFormState({
199
+ initialValues: {
200
+ y: '' as { a: string } | string,
201
+ },
202
+ });
203
+ });
204
+
205
+ it('rejects disallowed unions nested below the top level', () => {
206
+ // @ts-expect-error the policy applies at every depth, not just root keys
207
+ useFormState({
208
+ initialValues: {
209
+ wrapper: {
210
+ inner: { a: '' } as { a: string } | { b: number },
211
+ },
212
+ },
213
+ });
214
+
215
+ // @ts-expect-error … including inside list elements
216
+ useFormState({
217
+ initialValues: {
218
+ rows: [] as Array<{ cell: { a: string } | { b: number } }>,
219
+ },
220
+ });
221
+
222
+ // @ts-expect-error … and when the list's ELEMENT type is itself a union
223
+ useFormState({
224
+ initialValues: {
225
+ rows: [] as Array<{ a: string } | { b: number }>,
226
+ },
227
+ });
228
+ });
229
+
230
+ it('nullability does not launder a disallowed union', () => {
231
+ // @ts-expect-error `| undefined` on top of an object union is still a union of objects
232
+ useFormState({
233
+ initialValues: {
234
+ z: undefined as { a: string } | { b: number } | undefined,
235
+ },
236
+ });
237
+ });
238
+ });
239
+
136
240
  // ---------------------------------------------------------------------------
137
241
  // The recursion probe. Prior explorations of this design narrowed fine on
138
242
  // trivial examples but hit TS recursion limits on realistic form state. This
@@ -156,7 +260,23 @@ type InsuranceQuoteForm = {
156
260
  phone: string | undefined;
157
261
  dateOfBirth: string | undefined;
158
262
  homeAddress: UsAddress;
159
- mailingAddress: UsAddress;
263
+ // Optional nested section: absent until the user opts in.
264
+ mailingAddress: UsAddress | undefined;
265
+ // Optional section with a non-optional leaf inside — the probe that pins
266
+ // ancestor nullability surfacing in the leaf's resolved type.
267
+ coApplicant:
268
+ | {
269
+ firstName: string | undefined;
270
+ lastName: string | undefined;
271
+ sharesResidence: boolean;
272
+ }
273
+ | undefined;
274
+ // Nullable list: null until upstream quote data loads.
275
+ pastPolicies: Array<{
276
+ insurer: string | undefined;
277
+ policyNumber: string | undefined;
278
+ activeUntil: string | null;
279
+ }> | null;
160
280
  employer: string | null;
161
281
  jobTitle: string | null;
162
282
  yearsEmployed: number | null;
@@ -195,22 +315,42 @@ describe('useFormState narrowing at realistic scale', () => {
195
315
  initialValues: {} as InsuranceQuoteForm,
196
316
  constraints: {
197
317
  firstName: notEmpty('firstName'),
198
- lastName: notEmpty('lastName'),
199
- 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')],
200
323
  phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
201
- 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
+ ),
202
330
  yearsEmployed: min('yearsEmployed', 0),
203
331
  annualIncomeUsd: min('annualIncomeUsd', 0),
204
- 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
+ ],
205
341
  startDate: notEmpty('startDate'),
206
342
  referralSource: notEmpty('referralSource'),
207
- notes: minLength('notes', 10),
343
+ // Array of non-refining validators only.
344
+ notes: [minLength('notes', 10), matches('notes', /\S/, 'not blank')],
208
345
  agreedToTerms: notEmpty('agreedToTerms'),
209
346
  },
210
347
  onSubmit: (values) => {
211
348
  // Refined: notEmpty strips null/undefined/'' from the union.
212
349
  expectTypeOf(values.firstName).toEqualTypeOf<string>();
350
+ expectTypeOf(values.lastName).toEqualTypeOf<string>();
213
351
  expectTypeOf(values.email).toEqualTypeOf<string>();
352
+ expectTypeOf(values.dateOfBirth).toEqualTypeOf<string>();
353
+ expectTypeOf(values.coverageType).toEqualTypeOf<string>();
214
354
  expectTypeOf(values.referralSource).toEqualTypeOf<string>();
215
355
  expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
216
356
  // Constrained but non-refining validators leave the type alone.
@@ -220,6 +360,7 @@ describe('useFormState narrowing at realistic scale', () => {
220
360
  // Unconstrained fields — including all nested structure — untouched.
221
361
  expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
222
362
  expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
363
+ expectTypeOf(values.mailingAddress).toEqualTypeOf<UsAddress | undefined>();
223
364
  expectTypeOf(values.drivers).toEqualTypeOf<
224
365
  InsuranceQuoteForm['drivers']
225
366
  >();
@@ -228,6 +369,19 @@ describe('useFormState narrowing at realistic scale', () => {
228
369
  });
229
370
  });
230
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
+
231
385
  it('Path<T> still expands on the realistic form type', () => {
232
386
  // Path is the most recursion-prone type in forms/ — its union grows with
233
387
  // every key at every depth. Probe it with deep representative paths
@@ -245,4 +399,47 @@ describe('useFormState narrowing at realistic scale', () => {
245
399
  ValueAt<InsuranceQuoteForm, ['agreedToTerms']>
246
400
  >().toEqualTypeOf<boolean | undefined>();
247
401
  });
402
+
403
+ it('paths through optional sections and nullable lists resolve, at scale', () => {
404
+ // The latent hole this pins: Path admitted these paths all along, but
405
+ // ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
406
+ // `never`. Stepping through a nullable ancestor must instead surface as
407
+ // `| undefined` in the resolved type — matching read()'s runtime behavior
408
+ // of returning undefined when a step hits a dead value.
409
+ type P = Path<InsuranceQuoteForm>;
410
+ expectTypeOf<['mailingAddress', 'city']>().toMatchTypeOf<P>();
411
+ expectTypeOf<['coApplicant', 'sharesResidence']>().toMatchTypeOf<P>();
412
+ expectTypeOf<['pastPolicies', number, 'insurer']>().toMatchTypeOf<P>();
413
+
414
+ // Optional section: the section itself resolves exactly (no ancestor is
415
+ // nullable above it) …
416
+ expectTypeOf<
417
+ ValueAt<InsuranceQuoteForm, ['coApplicant']>
418
+ >().toEqualTypeOf<InsuranceQuoteForm['coApplicant']>();
419
+ // … and a non-optional leaf below it picks up `| undefined` from the
420
+ // ancestor.
421
+ expectTypeOf<
422
+ ValueAt<InsuranceQuoteForm, ['coApplicant', 'sharesResidence']>
423
+ >().toEqualTypeOf<boolean | undefined>();
424
+ expectTypeOf<
425
+ ValueAt<InsuranceQuoteForm, ['mailingAddress', 'city']>
426
+ >().toEqualTypeOf<string | undefined>();
427
+
428
+ // Nullable list: the list itself resolves exactly (`| null` preserved) …
429
+ expectTypeOf<
430
+ ValueAt<InsuranceQuoteForm, ['pastPolicies']>
431
+ >().toEqualTypeOf<InsuranceQuoteForm['pastPolicies']>();
432
+ // … while stepping *through* the null surfaces as `| undefined` (not
433
+ // `| null`) — read() returns undefined for a dead step regardless of
434
+ // whether the dead value was null or undefined.
435
+ expectTypeOf<
436
+ ValueAt<InsuranceQuoteForm, ['pastPolicies', number]>
437
+ >().toEqualTypeOf<
438
+ | { insurer: string | undefined; policyNumber: string | undefined; activeUntil: string | null }
439
+ | undefined
440
+ >();
441
+ expectTypeOf<
442
+ ValueAt<InsuranceQuoteForm, ['pastPolicies', number, 'activeUntil']>
443
+ >().toEqualTypeOf<string | null | undefined>();
444
+ });
248
445
  });
@@ -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(() =>
@@ -8,18 +8,29 @@ import type {
8
8
  FormErrors,
9
9
  FormHelpers,
10
10
  FormValuesObject,
11
+ UnionPolicyCheck,
11
12
  } from './types';
12
13
  import type { Refine, Validations } from '../validations/types';
14
+ import { validateEntry } from '../validations/walk';
15
+ import type { FlatConstraintEntry } from '../validations/walk';
13
16
 
14
17
  // `const V` freezes the inferred type of an inline `constraints` object —
15
18
  // each validator's precise type and Refinement marker survive without any
16
19
  // `as const` at the call site. Constraint objects built outside the call
17
20
  // still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
21
+ //
22
+ // The `UnionPolicyCheck<T>` intersection enforces the union policy (see
23
+ // "Union policy" in src/forms/CLAUDE.md) at the hook boundary: a form type
24
+ // containing a disallowed union fails here, naming the offending keys,
25
+ // rather than silently producing dead types deep inside a resolved path
26
+ // later. It must stay OUT of the `initialValues` property type — an
27
+ // intersection target there defeats literal widening during inference
28
+ // (`b: 0` infers as `0`, not `number`).
18
29
  type Args<T extends FormValuesObject, V extends Validations<T>> = {
19
30
  initialValues: T;
20
31
  constraints?: V;
21
32
  onSubmit?: (values: Refine<T, V>) => void;
22
- };
33
+ } & UnionPolicyCheck<T>;
23
34
 
24
35
  export const useFormState = <
25
36
  T extends FormValuesObject,
@@ -35,13 +46,12 @@ export const useFormState = <
35
46
  const errors: FormErrors<T> = {};
36
47
  if (constraints) {
37
48
  for (const key of Object.keys(constraints) as (keyof T & string)[]) {
38
- // Field type and validator input are correlated per key, but TS can't
39
- // track that through the union of keys widen the input to unknown.
40
- const validator = constraints[key] as
41
- | ((val: unknown) => string | null)
42
- | undefined;
43
- const error = validator?.(values[key]);
44
- 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;
45
55
  }
46
56
  }
47
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
+ });