@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
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -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
|
|
15
|
-
at the type level, refinement
|
|
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`, `
|
|
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
|
|
@@ -119,8 +168,82 @@ useFormState({
|
|
|
119
168
|
});
|
|
120
169
|
```
|
|
121
170
|
|
|
171
|
+
## Union policy — what form state may hold
|
|
172
|
+
|
|
173
|
+
The path machinery is load-bearing (structured `{path, error}[]` errors and
|
|
174
|
+
`getFormFieldPropsAt` both build on it), so its contract is strict: **every
|
|
175
|
+
path `Path<T>` admits must resolve to a correct, useful type via `ValueAt`.**
|
|
176
|
+
Unions against objects and arrays sank a previous incarnation of this kind of
|
|
177
|
+
form-state management; the lesson adopted here is to support the one union
|
|
178
|
+
form state genuinely needs and loudly reject the rest.
|
|
179
|
+
|
|
180
|
+
**Allowed — nullability on any field.** `Section | undefined`,
|
|
181
|
+
`Item[] | null`, etc. are everyday form state ("not filled in yet", "not
|
|
182
|
+
loaded yet"). Scalar unions (`'a' | 'b' | undefined`) are also fine — they
|
|
183
|
+
are `FormValueSimple` leaves. Resolution semantics, identical at the type
|
|
184
|
+
level (`ValueAt`) and runtime (`read()`):
|
|
185
|
+
|
|
186
|
+
- A path that stops **at** a nullable field resolves to the field's exact
|
|
187
|
+
type — `| null` preserved.
|
|
188
|
+
- A path that steps **through** a nullable ancestor picks up `| undefined` —
|
|
189
|
+
always `undefined`, never `null`, even when the ancestor's own nullability
|
|
190
|
+
is `null`, because `read()` returns `undefined` for any dead step. Chained
|
|
191
|
+
nullable ancestors contribute a single `| undefined`.
|
|
192
|
+
|
|
193
|
+
**Disallowed — every other union.** After stripping `null | undefined`, a
|
|
194
|
+
field must be exactly one shape. Unions of two object types (tagged or
|
|
195
|
+
untagged), object-vs-list, object-vs-scalar, list-vs-list are all rejected,
|
|
196
|
+
in three layers:
|
|
197
|
+
|
|
198
|
+
1. **At the `useFormState` boundary**: an illegal form type makes the call
|
|
199
|
+
fail with an unsatisfiable `'ERROR: form state disallows unions of
|
|
200
|
+
objects/lists …'` property whose type names the offending keys
|
|
201
|
+
(`UnionPolicyCheck<T>` in `useFormState/types.ts`).
|
|
202
|
+
2. **`Path<T>` refuses to descend** into a disallowed union — the field
|
|
203
|
+
stays addressable as a leaf (reading it yields the union, which is
|
|
204
|
+
honest), but no paths below it exist, so `.at(…)`/path literals into it
|
|
205
|
+
are compile errors.
|
|
206
|
+
3. **A hand-written `ValueAt` into one** resolves to the loud
|
|
207
|
+
`DisallowedFormUnion` marker — never a quiet `never`.
|
|
208
|
+
|
|
209
|
+
**Tagged/discriminated unions are deliberately deferred, not supported.**
|
|
210
|
+
Reliable per-variant path semantics would require detecting the discriminant
|
|
211
|
+
at the type level — fragile and expensive against the recursion budget.
|
|
212
|
+
Model variants as sibling optional sections plus a scalar discriminant
|
|
213
|
+
field, which the nullable-section support makes ergonomic:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// instead of: party: { kind: 'person'; … } | { kind: 'company'; … }
|
|
217
|
+
partyKind: 'person' | 'company' | undefined;
|
|
218
|
+
person: { name: string | undefined } | undefined;
|
|
219
|
+
company: { vat: string | undefined } | undefined;
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Known, accepted type/runtime divergence: indexing a *present* list types the
|
|
223
|
+
element without `| undefined` (standard TS array-indexing convention; we
|
|
224
|
+
don't use `noUncheckedIndexedAccess`), while `read()` returns `undefined`
|
|
225
|
+
out of bounds.
|
|
226
|
+
|
|
227
|
+
Maintainer note: `Path<T>` must remain a distributive conditional over naked
|
|
228
|
+
`T`. Wrapping the check type (e.g. `PathShapes<NonNullable<T>>`) makes the
|
|
229
|
+
constraint of `P extends Path<T>` uncomputable for generic `T` and blows the
|
|
230
|
+
recursion stack (TS2589) at `Cursor.at`. Nullability handling rides on that
|
|
231
|
+
distribution; the union-policy gate lives in `PathBelow`, applied at each
|
|
232
|
+
descent into a field.
|
|
233
|
+
|
|
122
234
|
## Caveats — known and accepted
|
|
123
235
|
|
|
236
|
+
- **Generic pass-through wrappers of `useFormState` are unsupported.** A
|
|
237
|
+
hook generic over `T extends FormValuesObject` that forwards
|
|
238
|
+
`initialValues` — e.g. `<T extends FormValuesObject>(init: T) =>
|
|
239
|
+
useFormState({ initialValues: init })` — fails to compile with TS2589
|
|
240
|
+
("Type instantiation is excessively deep"), because the boundary gate
|
|
241
|
+
`UnionPolicyCheck<T>` cannot resolve for an unresolved generic `T`. The
|
|
242
|
+
error names recursion depth, not the union policy — if you hit it in a
|
|
243
|
+
wrapper, this is why. Accepted tradeoff of the conditional-type boundary
|
|
244
|
+
gate, and congruent with intended usage: wrap the hook in a custom hook
|
|
245
|
+
that pre-applies a *concrete* form type instead — `T` then resolves
|
|
246
|
+
concretely and compiles fine.
|
|
124
247
|
- **`Exclude<string, ''>` is still `string`.** The empty-string refinement
|
|
125
248
|
only narrows fields whose type is a *union* containing the literal `''`.
|
|
126
249
|
For plain `string`, `notEmpty` still validates at runtime, but the
|
|
@@ -163,9 +286,21 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
163
286
|
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
164
287
|
Will be used for granular setters and for surfacing per-field
|
|
165
288
|
errors/touched state. Coupled to the form value model intentionally.
|
|
166
|
-
|
|
167
|
-
|
|
289
|
+
Union handling is governed by the "Union policy" section above; the
|
|
290
|
+
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
291
|
+
`DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
|
|
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
|
|
168
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.
|
|
169
304
|
- `validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
170
305
|
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
171
306
|
carries the union of the parts' refinements. `allOf` derives its input type
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { path, read } from './path';
|
|
3
|
+
|
|
4
|
+
// These tests pin read()'s runtime semantics to exactly what the type level
|
|
5
|
+
// (ValueAt) promises — see the union-semantics comments in ./types.ts and
|
|
6
|
+
// the "Union policy" section in src/forms/CLAUDE.md. The load-bearing rule:
|
|
7
|
+
// stepping THROUGH a dead value (undefined or null) yields undefined; a path
|
|
8
|
+
// that stops AT a nullable field yields the field's actual value, null
|
|
9
|
+
// included.
|
|
10
|
+
|
|
11
|
+
type Address = { city: string | undefined; zip: string };
|
|
12
|
+
|
|
13
|
+
type Form = {
|
|
14
|
+
name: string;
|
|
15
|
+
// optional nested section
|
|
16
|
+
address: Address | undefined;
|
|
17
|
+
// nullable list
|
|
18
|
+
entries: Array<{ title: string; qty: number }> | null;
|
|
19
|
+
// chained nullables
|
|
20
|
+
a: { b: { c: string } | null } | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const filled: Form = {
|
|
24
|
+
name: 'Ada',
|
|
25
|
+
address: { city: 'London', zip: 'N1' },
|
|
26
|
+
entries: [{ title: 'first', qty: 1 }],
|
|
27
|
+
a: { b: { c: 'deep' } },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const empty: Form = {
|
|
31
|
+
name: 'Ada',
|
|
32
|
+
address: undefined,
|
|
33
|
+
entries: null,
|
|
34
|
+
a: undefined,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('read through nullable ancestors', () => {
|
|
38
|
+
test('resolves normally when the nullable ancestor is present', () => {
|
|
39
|
+
const steps = path<Form>().at(['address', 'city']).build();
|
|
40
|
+
expect(read(filled, steps)).toBe('London');
|
|
41
|
+
|
|
42
|
+
const listSteps = path<Form>().at(['entries', 0, 'title']).build();
|
|
43
|
+
expect(read(filled, listSteps)).toBe('first');
|
|
44
|
+
|
|
45
|
+
const chained = path<Form>().at(['a', 'b', 'c']).build();
|
|
46
|
+
expect(read(filled, chained)).toBe('deep');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('returns undefined when an undefined ancestor is stepped through', () => {
|
|
50
|
+
const steps = path<Form>().at(['address', 'city']).build();
|
|
51
|
+
expect(read(empty, steps)).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('returns undefined when a null ancestor is stepped through — null never leaks', () => {
|
|
55
|
+
const steps = path<Form>().at(['entries', 0, 'title']).build();
|
|
56
|
+
expect(read(empty, steps)).toBeUndefined();
|
|
57
|
+
|
|
58
|
+
const midNull: Form = { ...filled, a: { b: null } };
|
|
59
|
+
const chained = path<Form>().at(['a', 'b', 'c']).build();
|
|
60
|
+
expect(read(midNull, chained)).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a path stopping AT a nullable field returns the actual value, null included', () => {
|
|
64
|
+
// Ancestors count for the through-a-dead-value rule; the addressed field
|
|
65
|
+
// itself does not — its own value comes back as-is. This is why ValueAt
|
|
66
|
+
// preserves `| null` on the field but converts ancestor nullability to
|
|
67
|
+
// `| undefined`.
|
|
68
|
+
expect(read(empty, path<Form>().at(['entries']).build())).toBeNull();
|
|
69
|
+
expect(read(empty, path<Form>().at(['address']).build())).toBeUndefined();
|
|
70
|
+
expect(read(filled, path<Form>().at(['entries']).build())).toEqual([
|
|
71
|
+
{ title: 'first', qty: 1 },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('read step failures', () => {
|
|
77
|
+
test('out-of-bounds list index returns undefined', () => {
|
|
78
|
+
// Known type/runtime divergence, TS-array-indexing style: ValueAt types
|
|
79
|
+
// a present list's element WITHOUT `| undefined`. Documented in
|
|
80
|
+
// src/forms/CLAUDE.md.
|
|
81
|
+
const steps = path<Form>().at(['entries', 5, 'title']).build();
|
|
82
|
+
expect(read(filled, steps)).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('numeric step on a non-list returns undefined', () => {
|
|
86
|
+
expect(read(filled, [{ kind: 'key', key: 0 }])).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('string step on a scalar returns undefined', () => {
|
|
90
|
+
expect(
|
|
91
|
+
read(filled, [
|
|
92
|
+
{ kind: 'key', key: 'name' },
|
|
93
|
+
{ kind: 'key', key: 'length' },
|
|
94
|
+
]),
|
|
95
|
+
).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rejecting narrow predicate returns undefined; accepting one passes through', () => {
|
|
99
|
+
const isString = (val: unknown): val is string => typeof val === 'string';
|
|
100
|
+
const narrowed = path<Form>()
|
|
101
|
+
.at(['name'])
|
|
102
|
+
.narrow((val): val is string => isString(val))
|
|
103
|
+
.build();
|
|
104
|
+
expect(read(filled, narrowed)).toBe('Ada');
|
|
105
|
+
|
|
106
|
+
const rejecting = [
|
|
107
|
+
{ kind: 'key' as const, key: 'name' },
|
|
108
|
+
{ kind: 'narrow' as const, predicate: (val: unknown) => typeof val === 'number' },
|
|
109
|
+
];
|
|
110
|
+
expect(read(filled, rejecting)).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/forms/path/path.ts
CHANGED
|
@@ -22,10 +22,13 @@ const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
|
|
|
22
22
|
|
|
23
23
|
export const path = <T>(): Cursor<T> => makeCursor<T>([]);
|
|
24
24
|
|
|
25
|
-
// Walk steps against a value. Returns undefined if any step fails —
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
25
|
+
// Walk steps against a value. Returns undefined if any step fails — a dead
|
|
26
|
+
// value (null or undefined) encountered mid-path, missing key, out-of-bounds
|
|
27
|
+
// index, or a narrow predicate that rejects the current value. Always
|
|
28
|
+
// undefined, never null, even when the dead value was null — ValueAt in
|
|
29
|
+
// ./types.ts mirrors this exactly (see "Union policy" in src/forms/CLAUDE.md).
|
|
30
|
+
// Callers using the typed cursor know what shape to expect; this is the
|
|
31
|
+
// runtime escape hatch that surfaces "the path didn't resolve."
|
|
29
32
|
export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
|
|
30
33
|
let cursor: unknown = root;
|
|
31
34
|
|
|
@@ -28,6 +28,30 @@ type Mixed = {
|
|
|
28
28
|
}>;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// Nullable ancestors — the one union form state supports. See the "Union
|
|
32
|
+
// policy" section in src/forms/CLAUDE.md.
|
|
33
|
+
type WithOptionalSection = {
|
|
34
|
+
name: string;
|
|
35
|
+
address: { city: string; zip: string | undefined } | undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type WithNullableList = {
|
|
39
|
+
entries: Array<{ title: string; qty: number }> | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type WithChainedNullables = {
|
|
43
|
+
a: { b: { c: string } | null } | undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Disallowed unions — everything that isn't "single shape, possibly
|
|
47
|
+
// null/undefined". Paths must not descend into these.
|
|
48
|
+
type Tagged = { kind: 'person'; name: string } | { kind: 'company'; vat: string };
|
|
49
|
+
type WithTaggedUnion = { party: Tagged };
|
|
50
|
+
type WithUntaggedUnion = { thing: { a: string } | { b: number } };
|
|
51
|
+
type WithObjOrList = { x: { a: string } | Array<{ a: string }> };
|
|
52
|
+
type WithObjOrScalar = { y: { a: string } | string };
|
|
53
|
+
type WithNullableDisallowed = { z: { a: string } | { b: number } | undefined };
|
|
54
|
+
|
|
31
55
|
describe('Path<T>', () => {
|
|
32
56
|
it('expands flat object keys to single-step paths', () => {
|
|
33
57
|
type P = Path<FlatObj>;
|
|
@@ -76,6 +100,32 @@ describe('Path<T>', () => {
|
|
|
76
100
|
expectTypeOf<Path<string[]>>().toEqualTypeOf<never>();
|
|
77
101
|
expectTypeOf<Path<undefined>>().toEqualTypeOf<never>();
|
|
78
102
|
});
|
|
103
|
+
|
|
104
|
+
it('descends into optional sections and nullable lists', () => {
|
|
105
|
+
expectTypeOf<Path<WithOptionalSection>>().toEqualTypeOf<
|
|
106
|
+
['name'] | ['address'] | ['address', 'city'] | ['address', 'zip']
|
|
107
|
+
>();
|
|
108
|
+
expectTypeOf<Path<WithNullableList>>().toEqualTypeOf<
|
|
109
|
+
| ['entries']
|
|
110
|
+
| ['entries', number]
|
|
111
|
+
| ['entries', number, 'title']
|
|
112
|
+
| ['entries', number, 'qty']
|
|
113
|
+
>();
|
|
114
|
+
expectTypeOf<Path<WithChainedNullables>>().toEqualTypeOf<
|
|
115
|
+
['a'] | ['a', 'b'] | ['a', 'b', 'c']
|
|
116
|
+
>();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('refuses to descend into disallowed unions — the field is a leaf', () => {
|
|
120
|
+
// The field itself stays addressable (reading it yields the union,
|
|
121
|
+
// which is honest); paths *into* it are never generated.
|
|
122
|
+
expectTypeOf<Path<WithTaggedUnion>>().toEqualTypeOf<['party']>();
|
|
123
|
+
expectTypeOf<Path<WithUntaggedUnion>>().toEqualTypeOf<['thing']>();
|
|
124
|
+
expectTypeOf<Path<WithObjOrList>>().toEqualTypeOf<['x']>();
|
|
125
|
+
expectTypeOf<Path<WithObjOrScalar>>().toEqualTypeOf<['y']>();
|
|
126
|
+
// Nullability does not launder a disallowed union into an allowed one.
|
|
127
|
+
expectTypeOf<Path<WithNullableDisallowed>>().toEqualTypeOf<['z']>();
|
|
128
|
+
});
|
|
79
129
|
});
|
|
80
130
|
|
|
81
131
|
describe('ValueAt<T, P>', () => {
|
|
@@ -129,6 +179,55 @@ describe('ValueAt<T, P>', () => {
|
|
|
129
179
|
type Bad = ValueAt<FlatObj, [number]>;
|
|
130
180
|
expectTypeOf<Bad>().toEqualTypeOf<never>();
|
|
131
181
|
});
|
|
182
|
+
|
|
183
|
+
it('resolves through an optional section as `V | undefined`', () => {
|
|
184
|
+
// The section itself resolves exactly …
|
|
185
|
+
expectTypeOf<ValueAt<WithOptionalSection, ['address']>>().toEqualTypeOf<
|
|
186
|
+
WithOptionalSection['address']
|
|
187
|
+
>();
|
|
188
|
+
// … a non-optional leaf below it picks up `| undefined` from the ancestor …
|
|
189
|
+
expectTypeOf<
|
|
190
|
+
ValueAt<WithOptionalSection, ['address', 'city']>
|
|
191
|
+
>().toEqualTypeOf<string | undefined>();
|
|
192
|
+
// … and an already-optional leaf stays `| undefined` (no double-counting).
|
|
193
|
+
expectTypeOf<
|
|
194
|
+
ValueAt<WithOptionalSection, ['address', 'zip']>
|
|
195
|
+
>().toEqualTypeOf<string | undefined>();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('resolves through a nullable list as `V | undefined`, never `V | null`', () => {
|
|
199
|
+
// Dead ancestors surface as `undefined` in the resolved type even when
|
|
200
|
+
// the ancestor's own nullability is `null` — read() returns undefined
|
|
201
|
+
// for any dead step, and the types mirror read() exactly.
|
|
202
|
+
expectTypeOf<ValueAt<WithNullableList, ['entries']>>().toEqualTypeOf<
|
|
203
|
+
WithNullableList['entries']
|
|
204
|
+
>();
|
|
205
|
+
expectTypeOf<
|
|
206
|
+
ValueAt<WithNullableList, ['entries', number]>
|
|
207
|
+
>().toEqualTypeOf<{ title: string; qty: number } | undefined>();
|
|
208
|
+
expectTypeOf<
|
|
209
|
+
ValueAt<WithNullableList, ['entries', number, 'title']>
|
|
210
|
+
>().toEqualTypeOf<string | undefined>();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('collapses chained nullable ancestors into a single `| undefined`', () => {
|
|
214
|
+
expectTypeOf<
|
|
215
|
+
ValueAt<WithChainedNullables, ['a', 'b', 'c']>
|
|
216
|
+
>().toEqualTypeOf<string | undefined>();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('resolves a disallowed-union field itself, but never into it', () => {
|
|
220
|
+
// The field is a leaf: reading it yields the union, honestly.
|
|
221
|
+
expectTypeOf<ValueAt<WithTaggedUnion, ['party']>>().toEqualTypeOf<Tagged>();
|
|
222
|
+
// Stepping *into* it is loud — a branded error object, not a quiet
|
|
223
|
+
// `never`. (Path<T> never admits these paths; this covers hand-written
|
|
224
|
+
// ValueAt uses.)
|
|
225
|
+
type Loud = ValueAt<WithTaggedUnion, ['party', 'kind']>;
|
|
226
|
+
expectTypeOf<Loud>().not.toEqualTypeOf<never>();
|
|
227
|
+
expectTypeOf<Loud>().toHaveProperty(
|
|
228
|
+
'ERROR: form state disallows unions of objects/lists — restructure this field',
|
|
229
|
+
);
|
|
230
|
+
});
|
|
132
231
|
});
|
|
133
232
|
|
|
134
233
|
describe('Cursor<T>', () => {
|
|
@@ -158,6 +257,31 @@ describe('Cursor<T>', () => {
|
|
|
158
257
|
path<NestedObj>().at(['user', 0]);
|
|
159
258
|
});
|
|
160
259
|
|
|
260
|
+
it('tracks `| undefined` through nullable ancestors', () => {
|
|
261
|
+
const c = path<WithOptionalSection>().at(['address', 'city']);
|
|
262
|
+
expectTypeOf(c).toEqualTypeOf<Cursor<string | undefined>>();
|
|
263
|
+
|
|
264
|
+
const el = path<WithNullableList>().at(['entries', 0]);
|
|
265
|
+
expectTypeOf(el).toEqualTypeOf<
|
|
266
|
+
Cursor<{ title: string; qty: number } | undefined>
|
|
267
|
+
>();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('rejects stepping into disallowed unions at the call site', () => {
|
|
271
|
+
// @ts-expect-error tagged unions are not path-addressable below the field
|
|
272
|
+
path<WithTaggedUnion>().at(['party', 'kind']);
|
|
273
|
+
|
|
274
|
+
// @ts-expect-error untagged object unions are not path-addressable below the field
|
|
275
|
+
path<WithUntaggedUnion>().at(['thing', 'a']);
|
|
276
|
+
|
|
277
|
+
// @ts-expect-error object-vs-list unions are not path-addressable below the field
|
|
278
|
+
path<WithObjOrList>().at(['x', 0]);
|
|
279
|
+
|
|
280
|
+
// the field itself remains addressable
|
|
281
|
+
const c = path<WithTaggedUnion>().at(['party']);
|
|
282
|
+
expectTypeOf(c).toEqualTypeOf<Cursor<Tagged>>();
|
|
283
|
+
});
|
|
284
|
+
|
|
161
285
|
it('narrow() refines the cursor type', () => {
|
|
162
286
|
const c = path<{ value: string | number }>().at(['value']);
|
|
163
287
|
expectTypeOf(c).toEqualTypeOf<Cursor<string | number>>();
|
package/src/forms/path/types.ts
CHANGED
|
@@ -1,28 +1,71 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
DisallowedFormUnion,
|
|
3
|
+
FormValueList,
|
|
4
|
+
FormValuesObject,
|
|
5
|
+
IsDisallowedFormUnion,
|
|
6
|
+
} from '../useFormState/types';
|
|
2
7
|
|
|
3
8
|
export type PathStep = string | number;
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// Union semantics (the full policy lives in src/forms/CLAUDE.md):
|
|
11
|
+
// - Nullability is handled here, once, at the top: `Section | undefined` and
|
|
12
|
+
// `Item[] | null` are everyday form state, so paths descend through them
|
|
13
|
+
// as if the shape were present.
|
|
14
|
+
// - Disallowed unions (object|object, object|list, object|scalar, list|list)
|
|
15
|
+
// get NO paths into them — the field stays addressable as a leaf, which is
|
|
16
|
+
// honest (reading it yields the union), but nothing below it is.
|
|
17
|
+
// Path<T> must stay a distributive conditional over naked T: wrapping the
|
|
18
|
+
// check type (e.g. `PathShapes<NonNullable<T>>`) makes the constraint of
|
|
19
|
+
// `P extends Path<T>` uncomputable for generic T and blows the recursion
|
|
20
|
+
// stack at Cursor.at (TS2589). Distribution also IS the nullability
|
|
21
|
+
// handling here — the undefined/null members of a nullable section fall
|
|
22
|
+
// into the final `never` arm and drop out of the union.
|
|
23
|
+
export type Path<T> = T extends FormValuesObject
|
|
24
|
+
? {
|
|
25
|
+
[K in Extract<keyof T, PathStep>]: [K] | [K, ...PathBelow<T[K]>];
|
|
26
|
+
}[Extract<keyof T, PathStep>]
|
|
27
|
+
: T extends FormValueList
|
|
28
|
+
? [number] | [number, ...PathBelow<T[number]>]
|
|
29
|
+
: never;
|
|
13
30
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
// The union-policy gate, applied at every descent into a field: a
|
|
32
|
+
// disallowed union gets no paths below it (the spread of `never` erases the
|
|
33
|
+
// longer tuple, leaving the field addressable only as a leaf). The root T
|
|
34
|
+
// is a FormValuesObject — never itself a union — so gating descents gates
|
|
35
|
+
// everything.
|
|
36
|
+
type PathBelow<F> =
|
|
37
|
+
IsDisallowedFormUnion<NonNullable<F>> extends true ? never : Path<F>;
|
|
38
|
+
|
|
39
|
+
// Resolution mirrors read() exactly: stepping THROUGH a nullable value adds
|
|
40
|
+
// `| undefined` to the result — undefined even when the ancestor's own
|
|
41
|
+
// nullability is `null`, because read() returns undefined for any dead step.
|
|
42
|
+
// A path that stops AT a nullable field resolves to the field's exact type
|
|
43
|
+
// (`| null` preserved); only ancestors count. Stepping into a disallowed
|
|
44
|
+
// union resolves to the loud DisallowedFormUnion marker, never a quiet
|
|
45
|
+
// `never` — though Path<T> refuses to admit such paths in the first place.
|
|
46
|
+
export type ValueAt<T, P extends readonly PathStep[]> = P extends readonly [
|
|
47
|
+
infer Head extends PathStep,
|
|
48
|
+
...infer Rest extends readonly PathStep[],
|
|
49
|
+
]
|
|
50
|
+
? [T] extends [NonNullable<T>]
|
|
51
|
+
? ValueAtStep<T, Head, Rest>
|
|
52
|
+
: ValueAtStep<NonNullable<T>, Head, Rest> extends infer V
|
|
53
|
+
? [V] extends [never]
|
|
54
|
+
? never // an invalid step stays never — don't launder it into undefined
|
|
55
|
+
: V | undefined
|
|
24
56
|
: never
|
|
25
|
-
|
|
57
|
+
: T;
|
|
58
|
+
|
|
59
|
+
type ValueAtStep<T, Head extends PathStep, Rest extends readonly PathStep[]> =
|
|
60
|
+
IsDisallowedFormUnion<T> extends true
|
|
61
|
+
? DisallowedFormUnion<T>
|
|
62
|
+
: Head extends number
|
|
63
|
+
? [T] extends [FormValueList]
|
|
64
|
+
? ValueAt<T[number], Rest>
|
|
65
|
+
: never
|
|
66
|
+
: Head extends keyof T
|
|
67
|
+
? ValueAt<T[Head], Rest>
|
|
68
|
+
: never;
|
|
26
69
|
|
|
27
70
|
export type CursorStep =
|
|
28
71
|
| { kind: 'key'; key: PathStep }
|
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`.
|
|
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,
|
|
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
|
-
`
|
|
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).
|
|
@@ -396,3 +419,14 @@ editable.
|
|
|
396
419
|
|
|
397
420
|
*(Resolved: error path format. Structured `{path, error}[]` with typed
|
|
398
421
|
paths — never exposed serialized-string keys. See "Runtime consequences".)*
|
|
422
|
+
|
|
423
|
+
*(Resolved: unions in form state. Nullability (`Section | undefined`,
|
|
424
|
+
`Item[] | null`) is fully supported by `Path`/`ValueAt`/`read()` — stepping
|
|
425
|
+
through a dead ancestor resolves as `| undefined`, identically at the type
|
|
426
|
+
level and at runtime. Every other union against objects/lists — including
|
|
427
|
+
tagged unions, deliberately deferred — is rejected at the `useFormState`
|
|
428
|
+
boundary and unaddressable below the field by `Path`. Full policy, the
|
|
429
|
+
recommended variant-modeling pattern, and the `Path` distributivity
|
|
430
|
+
constraint (TS2589) are in forms/CLAUDE.md, "Union policy". The error-model
|
|
431
|
+
swap and `getFormFieldPropsAt` can rely on `ValueAt` being truthful for
|
|
432
|
+
every admitted path.)*
|