@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -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 validator functions plus,
15
- at the type level, refinement markers carried by each validator.
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`, `oneOf`, `matches`) expose a phantom
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
- - `validations/` `perField`, `Validations<T>`, `Refine<T,V>`,
167
- `Refinement<>` infra. `Validations<T>` accepts bare
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
+ });
@@ -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 — missing
26
- // key, out-of-bounds index, or a narrow predicate that rejects the current
27
- // value. Callers using the typed cursor know what shape to expect; this is
28
- // the runtime escape hatch that surfaces "the path didn't resolve."
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>>();
@@ -1,28 +1,71 @@
1
- import type { FormValuesObject, FormValueList } from '../useFormState/types';
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
- export type Path<T> =
6
- T extends FormValuesObject
7
- ? {
8
- [K in Extract<keyof T, PathStep>]: [K] | [K, ...Path<T[K]>];
9
- }[Extract<keyof T, PathStep>]
10
- : T extends FormValueList
11
- ? [number] | [number, ...Path<T[number]>]
12
- : never;
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
- export type ValueAt<T, P extends readonly PathStep[]> =
15
- P extends readonly [infer Head, ...infer Rest]
16
- ? Rest extends readonly PathStep[]
17
- ? Head extends number
18
- ? T extends FormValueList
19
- ? ValueAt<T[number], Rest>
20
- : never
21
- : Head extends keyof T
22
- ? ValueAt<T[Head], Rest>
23
- : never
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
- : T;
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`. *current*
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, ExcludedOf<C[number]>> // validator array: union of excludes
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
- `ExcludedOf<C[number]>`.
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.)*