@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.
- package/package.json +1 -1
- package/src/forms/CLAUDE.md +140 -5
- package/src/forms/path/path.test.ts +112 -0
- package/src/forms/path/path.ts +7 -4
- package/src/forms/path/types.test-d.ts +124 -0
- package/src/forms/path/types.ts +63 -20
- package/src/forms/plan.md +37 -3
- package/src/forms/useFormState/types.ts +66 -0
- package/src/forms/useFormState/useFormState.stories.tsx +11 -8
- package/src/forms/useFormState/useFormState.test-d.ts +203 -6
- package/src/forms/useFormState/useFormState.test.tsx +60 -0
- package/src/forms/useFormState/useFormState.ts +18 -8
- package/src/forms/validations/perField.ts +4 -1
- package/src/forms/validations/types.test-d.ts +125 -0
- package/src/forms/validations/types.ts +78 -14
- package/src/forms/validations/walk.test.ts +75 -0
- package/src/forms/validations/walk.ts +51 -0
- package/src/forms/validators/validators.ts +7 -7
|
@@ -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 {
|
|
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
|
-
|
|
57
|
-
|
|
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,
|
|
158
|
-
// progression, live clearing, and the narrowed payload
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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<
|
|
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
|
+
});
|