@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
|
@@ -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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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]?:
|
|
37
|
+
readonly [K in keyof T]?: FieldConstraint<T[K]>;
|
|
26
38
|
};
|
|
27
39
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
79
|
-
// `
|
|
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>,
|
|
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<
|
|
94
|
+
{} as Refinement<MemberExcludes<Validators>>,
|
|
95
95
|
);
|