@structuralists/scaffolding 0.7.0 → 0.8.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 +77 -0
- 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 +11 -0
- package/src/forms/useFormState/types.ts +66 -0
- package/src/forms/useFormState/useFormState.test-d.ts +153 -1
- package/src/forms/useFormState/useFormState.ts +10 -1
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -119,8 +119,82 @@ useFormState({
|
|
|
119
119
|
});
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
## Union policy — what form state may hold
|
|
123
|
+
|
|
124
|
+
The path machinery is load-bearing (structured `{path, error}[]` errors and
|
|
125
|
+
`getFormFieldPropsAt` both build on it), so its contract is strict: **every
|
|
126
|
+
path `Path<T>` admits must resolve to a correct, useful type via `ValueAt`.**
|
|
127
|
+
Unions against objects and arrays sank a previous incarnation of this kind of
|
|
128
|
+
form-state management; the lesson adopted here is to support the one union
|
|
129
|
+
form state genuinely needs and loudly reject the rest.
|
|
130
|
+
|
|
131
|
+
**Allowed — nullability on any field.** `Section | undefined`,
|
|
132
|
+
`Item[] | null`, etc. are everyday form state ("not filled in yet", "not
|
|
133
|
+
loaded yet"). Scalar unions (`'a' | 'b' | undefined`) are also fine — they
|
|
134
|
+
are `FormValueSimple` leaves. Resolution semantics, identical at the type
|
|
135
|
+
level (`ValueAt`) and runtime (`read()`):
|
|
136
|
+
|
|
137
|
+
- A path that stops **at** a nullable field resolves to the field's exact
|
|
138
|
+
type — `| null` preserved.
|
|
139
|
+
- A path that steps **through** a nullable ancestor picks up `| undefined` —
|
|
140
|
+
always `undefined`, never `null`, even when the ancestor's own nullability
|
|
141
|
+
is `null`, because `read()` returns `undefined` for any dead step. Chained
|
|
142
|
+
nullable ancestors contribute a single `| undefined`.
|
|
143
|
+
|
|
144
|
+
**Disallowed — every other union.** After stripping `null | undefined`, a
|
|
145
|
+
field must be exactly one shape. Unions of two object types (tagged or
|
|
146
|
+
untagged), object-vs-list, object-vs-scalar, list-vs-list are all rejected,
|
|
147
|
+
in three layers:
|
|
148
|
+
|
|
149
|
+
1. **At the `useFormState` boundary**: an illegal form type makes the call
|
|
150
|
+
fail with an unsatisfiable `'ERROR: form state disallows unions of
|
|
151
|
+
objects/lists …'` property whose type names the offending keys
|
|
152
|
+
(`UnionPolicyCheck<T>` in `useFormState/types.ts`).
|
|
153
|
+
2. **`Path<T>` refuses to descend** into a disallowed union — the field
|
|
154
|
+
stays addressable as a leaf (reading it yields the union, which is
|
|
155
|
+
honest), but no paths below it exist, so `.at(…)`/path literals into it
|
|
156
|
+
are compile errors.
|
|
157
|
+
3. **A hand-written `ValueAt` into one** resolves to the loud
|
|
158
|
+
`DisallowedFormUnion` marker — never a quiet `never`.
|
|
159
|
+
|
|
160
|
+
**Tagged/discriminated unions are deliberately deferred, not supported.**
|
|
161
|
+
Reliable per-variant path semantics would require detecting the discriminant
|
|
162
|
+
at the type level — fragile and expensive against the recursion budget.
|
|
163
|
+
Model variants as sibling optional sections plus a scalar discriminant
|
|
164
|
+
field, which the nullable-section support makes ergonomic:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
// instead of: party: { kind: 'person'; … } | { kind: 'company'; … }
|
|
168
|
+
partyKind: 'person' | 'company' | undefined;
|
|
169
|
+
person: { name: string | undefined } | undefined;
|
|
170
|
+
company: { vat: string | undefined } | undefined;
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Known, accepted type/runtime divergence: indexing a *present* list types the
|
|
174
|
+
element without `| undefined` (standard TS array-indexing convention; we
|
|
175
|
+
don't use `noUncheckedIndexedAccess`), while `read()` returns `undefined`
|
|
176
|
+
out of bounds.
|
|
177
|
+
|
|
178
|
+
Maintainer note: `Path<T>` must remain a distributive conditional over naked
|
|
179
|
+
`T`. Wrapping the check type (e.g. `PathShapes<NonNullable<T>>`) makes the
|
|
180
|
+
constraint of `P extends Path<T>` uncomputable for generic `T` and blows the
|
|
181
|
+
recursion stack (TS2589) at `Cursor.at`. Nullability handling rides on that
|
|
182
|
+
distribution; the union-policy gate lives in `PathBelow`, applied at each
|
|
183
|
+
descent into a field.
|
|
184
|
+
|
|
122
185
|
## Caveats — known and accepted
|
|
123
186
|
|
|
187
|
+
- **Generic pass-through wrappers of `useFormState` are unsupported.** A
|
|
188
|
+
hook generic over `T extends FormValuesObject` that forwards
|
|
189
|
+
`initialValues` — e.g. `<T extends FormValuesObject>(init: T) =>
|
|
190
|
+
useFormState({ initialValues: init })` — fails to compile with TS2589
|
|
191
|
+
("Type instantiation is excessively deep"), because the boundary gate
|
|
192
|
+
`UnionPolicyCheck<T>` cannot resolve for an unresolved generic `T`. The
|
|
193
|
+
error names recursion depth, not the union policy — if you hit it in a
|
|
194
|
+
wrapper, this is why. Accepted tradeoff of the conditional-type boundary
|
|
195
|
+
gate, and congruent with intended usage: wrap the hook in a custom hook
|
|
196
|
+
that pre-applies a *concrete* form type instead — `T` then resolves
|
|
197
|
+
concretely and compiles fine.
|
|
124
198
|
- **`Exclude<string, ''>` is still `string`.** The empty-string refinement
|
|
125
199
|
only narrows fields whose type is a *union* containing the literal `''`.
|
|
126
200
|
For plain `string`, `notEmpty` still validates at runtime, but the
|
|
@@ -163,6 +237,9 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
163
237
|
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
164
238
|
Will be used for granular setters and for surfacing per-field
|
|
165
239
|
errors/touched state. Coupled to the form value model intentionally.
|
|
240
|
+
Union handling is governed by the "Union policy" section above; the
|
|
241
|
+
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
242
|
+
`DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
|
|
166
243
|
- `validations/` — `perField`, `Validations<T>`, `Refine<T,V>`,
|
|
167
244
|
`Refinement<>` infra. `Validations<T>` accepts bare
|
|
168
245
|
`(val) => string | null` functions too — they simply narrow nothing.
|
|
@@ -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
|
@@ -396,3 +396,14 @@ editable.
|
|
|
396
396
|
|
|
397
397
|
*(Resolved: error path format. Structured `{path, error}[]` with typed
|
|
398
398
|
paths — never exposed serialized-string keys. See "Runtime consequences".)*
|
|
399
|
+
|
|
400
|
+
*(Resolved: unions in form state. Nullability (`Section | undefined`,
|
|
401
|
+
`Item[] | null`) is fully supported by `Path`/`ValueAt`/`read()` — stepping
|
|
402
|
+
through a dead ancestor resolves as `| undefined`, identically at the type
|
|
403
|
+
level and at runtime. Every other union against objects/lists — including
|
|
404
|
+
tagged unions, deliberately deferred — is rejected at the `useFormState`
|
|
405
|
+
boundary and unaddressable below the field by `Path`. Full policy, the
|
|
406
|
+
recommended variant-modeling pattern, and the `Path` distributivity
|
|
407
|
+
constraint (TS2589) are in forms/CLAUDE.md, "Union policy". The error-model
|
|
408
|
+
swap and `getFormFieldPropsAt` can rely on `ValueAt` being truthful for
|
|
409
|
+
every admitted path.)*
|
|
@@ -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
|
>;
|
|
@@ -133,6 +133,98 @@ describe('useFormState onSubmit narrowing — pre-built constraints', () => {
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Union policy at the hook boundary (see "Union policy" in src/forms/CLAUDE.md):
|
|
138
|
+
// nullability is the one union form state supports on object/list fields.
|
|
139
|
+
// Everything else — tagged or untagged object unions, object|list,
|
|
140
|
+
// object|scalar — is rejected at the useFormState call, naming the offending
|
|
141
|
+
// keys, instead of silently producing dead types in resolved paths later.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe('useFormState union policy at the boundary', () => {
|
|
145
|
+
it('accepts optional sections and nullable lists', () => {
|
|
146
|
+
useFormState({
|
|
147
|
+
initialValues: {
|
|
148
|
+
home: undefined as { city: string | undefined } | undefined,
|
|
149
|
+
entries: null as Array<{ title: string }> | null,
|
|
150
|
+
},
|
|
151
|
+
onSubmit: (values) => {
|
|
152
|
+
expectTypeOf(values.home).toEqualTypeOf<
|
|
153
|
+
{ city: string | undefined } | undefined
|
|
154
|
+
>();
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects tagged object unions', () => {
|
|
160
|
+
type Party =
|
|
161
|
+
| { kind: 'person'; name: string }
|
|
162
|
+
| { kind: 'company'; vat: string };
|
|
163
|
+
|
|
164
|
+
// @ts-expect-error tagged object unions are disallowed in form state
|
|
165
|
+
useFormState({
|
|
166
|
+
initialValues: { party: { kind: 'person', name: 'Ada' } as Party },
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects untagged object unions, object|list, and object|scalar', () => {
|
|
171
|
+
// @ts-expect-error untagged object unions are disallowed in form state
|
|
172
|
+
useFormState({
|
|
173
|
+
initialValues: {
|
|
174
|
+
thing: { a: '' } as { a: string } | { b: number },
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// @ts-expect-error object|list unions are disallowed in form state
|
|
179
|
+
useFormState({
|
|
180
|
+
initialValues: {
|
|
181
|
+
x: [] as Array<{ a: string }> | { a: string },
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// @ts-expect-error object|scalar unions are disallowed in form state
|
|
186
|
+
useFormState({
|
|
187
|
+
initialValues: {
|
|
188
|
+
y: '' as { a: string } | string,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects disallowed unions nested below the top level', () => {
|
|
194
|
+
// @ts-expect-error the policy applies at every depth, not just root keys
|
|
195
|
+
useFormState({
|
|
196
|
+
initialValues: {
|
|
197
|
+
wrapper: {
|
|
198
|
+
inner: { a: '' } as { a: string } | { b: number },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// @ts-expect-error … including inside list elements
|
|
204
|
+
useFormState({
|
|
205
|
+
initialValues: {
|
|
206
|
+
rows: [] as Array<{ cell: { a: string } | { b: number } }>,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// @ts-expect-error … and when the list's ELEMENT type is itself a union
|
|
211
|
+
useFormState({
|
|
212
|
+
initialValues: {
|
|
213
|
+
rows: [] as Array<{ a: string } | { b: number }>,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('nullability does not launder a disallowed union', () => {
|
|
219
|
+
// @ts-expect-error `| undefined` on top of an object union is still a union of objects
|
|
220
|
+
useFormState({
|
|
221
|
+
initialValues: {
|
|
222
|
+
z: undefined as { a: string } | { b: number } | undefined,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
136
228
|
// ---------------------------------------------------------------------------
|
|
137
229
|
// The recursion probe. Prior explorations of this design narrowed fine on
|
|
138
230
|
// trivial examples but hit TS recursion limits on realistic form state. This
|
|
@@ -156,7 +248,23 @@ type InsuranceQuoteForm = {
|
|
|
156
248
|
phone: string | undefined;
|
|
157
249
|
dateOfBirth: string | undefined;
|
|
158
250
|
homeAddress: UsAddress;
|
|
159
|
-
|
|
251
|
+
// Optional nested section: absent until the user opts in.
|
|
252
|
+
mailingAddress: UsAddress | undefined;
|
|
253
|
+
// Optional section with a non-optional leaf inside — the probe that pins
|
|
254
|
+
// ancestor nullability surfacing in the leaf's resolved type.
|
|
255
|
+
coApplicant:
|
|
256
|
+
| {
|
|
257
|
+
firstName: string | undefined;
|
|
258
|
+
lastName: string | undefined;
|
|
259
|
+
sharesResidence: boolean;
|
|
260
|
+
}
|
|
261
|
+
| undefined;
|
|
262
|
+
// Nullable list: null until upstream quote data loads.
|
|
263
|
+
pastPolicies: Array<{
|
|
264
|
+
insurer: string | undefined;
|
|
265
|
+
policyNumber: string | undefined;
|
|
266
|
+
activeUntil: string | null;
|
|
267
|
+
}> | null;
|
|
160
268
|
employer: string | null;
|
|
161
269
|
jobTitle: string | null;
|
|
162
270
|
yearsEmployed: number | null;
|
|
@@ -220,6 +328,7 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
220
328
|
// Unconstrained fields — including all nested structure — untouched.
|
|
221
329
|
expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
|
|
222
330
|
expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
|
|
331
|
+
expectTypeOf(values.mailingAddress).toEqualTypeOf<UsAddress | undefined>();
|
|
223
332
|
expectTypeOf(values.drivers).toEqualTypeOf<
|
|
224
333
|
InsuranceQuoteForm['drivers']
|
|
225
334
|
>();
|
|
@@ -245,4 +354,47 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
245
354
|
ValueAt<InsuranceQuoteForm, ['agreedToTerms']>
|
|
246
355
|
>().toEqualTypeOf<boolean | undefined>();
|
|
247
356
|
});
|
|
357
|
+
|
|
358
|
+
it('paths through optional sections and nullable lists resolve, at scale', () => {
|
|
359
|
+
// The latent hole this pins: Path admitted these paths all along, but
|
|
360
|
+
// ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
|
|
361
|
+
// `never`. Stepping through a nullable ancestor must instead surface as
|
|
362
|
+
// `| undefined` in the resolved type — matching read()'s runtime behavior
|
|
363
|
+
// of returning undefined when a step hits a dead value.
|
|
364
|
+
type P = Path<InsuranceQuoteForm>;
|
|
365
|
+
expectTypeOf<['mailingAddress', 'city']>().toMatchTypeOf<P>();
|
|
366
|
+
expectTypeOf<['coApplicant', 'sharesResidence']>().toMatchTypeOf<P>();
|
|
367
|
+
expectTypeOf<['pastPolicies', number, 'insurer']>().toMatchTypeOf<P>();
|
|
368
|
+
|
|
369
|
+
// Optional section: the section itself resolves exactly (no ancestor is
|
|
370
|
+
// nullable above it) …
|
|
371
|
+
expectTypeOf<
|
|
372
|
+
ValueAt<InsuranceQuoteForm, ['coApplicant']>
|
|
373
|
+
>().toEqualTypeOf<InsuranceQuoteForm['coApplicant']>();
|
|
374
|
+
// … and a non-optional leaf below it picks up `| undefined` from the
|
|
375
|
+
// ancestor.
|
|
376
|
+
expectTypeOf<
|
|
377
|
+
ValueAt<InsuranceQuoteForm, ['coApplicant', 'sharesResidence']>
|
|
378
|
+
>().toEqualTypeOf<boolean | undefined>();
|
|
379
|
+
expectTypeOf<
|
|
380
|
+
ValueAt<InsuranceQuoteForm, ['mailingAddress', 'city']>
|
|
381
|
+
>().toEqualTypeOf<string | undefined>();
|
|
382
|
+
|
|
383
|
+
// Nullable list: the list itself resolves exactly (`| null` preserved) …
|
|
384
|
+
expectTypeOf<
|
|
385
|
+
ValueAt<InsuranceQuoteForm, ['pastPolicies']>
|
|
386
|
+
>().toEqualTypeOf<InsuranceQuoteForm['pastPolicies']>();
|
|
387
|
+
// … while stepping *through* the null surfaces as `| undefined` (not
|
|
388
|
+
// `| null`) — read() returns undefined for a dead step regardless of
|
|
389
|
+
// whether the dead value was null or undefined.
|
|
390
|
+
expectTypeOf<
|
|
391
|
+
ValueAt<InsuranceQuoteForm, ['pastPolicies', number]>
|
|
392
|
+
>().toEqualTypeOf<
|
|
393
|
+
| { insurer: string | undefined; policyNumber: string | undefined; activeUntil: string | null }
|
|
394
|
+
| undefined
|
|
395
|
+
>();
|
|
396
|
+
expectTypeOf<
|
|
397
|
+
ValueAt<InsuranceQuoteForm, ['pastPolicies', number, 'activeUntil']>
|
|
398
|
+
>().toEqualTypeOf<string | null | undefined>();
|
|
399
|
+
});
|
|
248
400
|
});
|
|
@@ -8,6 +8,7 @@ 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';
|
|
13
14
|
|
|
@@ -15,11 +16,19 @@ import type { Refine, Validations } from '../validations/types';
|
|
|
15
16
|
// each validator's precise type and Refinement marker survive without any
|
|
16
17
|
// `as const` at the call site. Constraint objects built outside the call
|
|
17
18
|
// still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
|
|
19
|
+
//
|
|
20
|
+
// The `UnionPolicyCheck<T>` intersection enforces the union policy (see
|
|
21
|
+
// "Union policy" in src/forms/CLAUDE.md) at the hook boundary: a form type
|
|
22
|
+
// containing a disallowed union fails here, naming the offending keys,
|
|
23
|
+
// rather than silently producing dead types deep inside a resolved path
|
|
24
|
+
// later. It must stay OUT of the `initialValues` property type — an
|
|
25
|
+
// intersection target there defeats literal widening during inference
|
|
26
|
+
// (`b: 0` infers as `0`, not `number`).
|
|
18
27
|
type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
19
28
|
initialValues: T;
|
|
20
29
|
constraints?: V;
|
|
21
30
|
onSubmit?: (values: Refine<T, V>) => void;
|
|
22
|
-
}
|
|
31
|
+
} & UnionPolicyCheck<T>;
|
|
23
32
|
|
|
24
33
|
export const useFormState = <
|
|
25
34
|
T extends FormValuesObject,
|