@structuralists/scaffolding 0.6.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -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
+ });
@@ -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
 
@@ -5,6 +5,7 @@ import type { Cursor, Path, ValueAt } from './types';
5
5
  type FlatObj = {
6
6
  name: string;
7
7
  age: number;
8
+ active: boolean;
8
9
  tags: string[]; // string[] is a FormValueSimple — treated as a leaf
9
10
  };
10
11
 
@@ -27,10 +28,34 @@ type Mixed = {
27
28
  }>;
28
29
  };
29
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
+
30
55
  describe('Path<T>', () => {
31
56
  it('expands flat object keys to single-step paths', () => {
32
57
  type P = Path<FlatObj>;
33
- expectTypeOf<P>().toEqualTypeOf<['name'] | ['age'] | ['tags']>();
58
+ expectTypeOf<P>().toEqualTypeOf<['name'] | ['age'] | ['active'] | ['tags']>();
34
59
  });
35
60
 
36
61
  it('descends into nested objects', () => {
@@ -71,9 +96,36 @@ describe('Path<T>', () => {
71
96
  it('produces `never` for FormValueSimple leaves', () => {
72
97
  expectTypeOf<Path<string>>().toEqualTypeOf<never>();
73
98
  expectTypeOf<Path<number>>().toEqualTypeOf<never>();
99
+ expectTypeOf<Path<boolean>>().toEqualTypeOf<never>();
74
100
  expectTypeOf<Path<string[]>>().toEqualTypeOf<never>();
75
101
  expectTypeOf<Path<undefined>>().toEqualTypeOf<never>();
76
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
+ });
77
129
  });
78
130
 
79
131
  describe('ValueAt<T, P>', () => {
@@ -84,6 +136,7 @@ describe('ValueAt<T, P>', () => {
84
136
  it('resolves a single key', () => {
85
137
  expectTypeOf<ValueAt<FlatObj, ['name']>>().toEqualTypeOf<string>();
86
138
  expectTypeOf<ValueAt<FlatObj, ['age']>>().toEqualTypeOf<number>();
139
+ expectTypeOf<ValueAt<FlatObj, ['active']>>().toEqualTypeOf<boolean>();
87
140
  expectTypeOf<ValueAt<FlatObj, ['tags']>>().toEqualTypeOf<string[]>();
88
141
  });
89
142
 
@@ -126,6 +179,55 @@ describe('ValueAt<T, P>', () => {
126
179
  type Bad = ValueAt<FlatObj, [number]>;
127
180
  expectTypeOf<Bad>().toEqualTypeOf<never>();
128
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
+ });
129
231
  });
130
232
 
131
233
  describe('Cursor<T>', () => {
@@ -155,6 +257,31 @@ describe('Cursor<T>', () => {
155
257
  path<NestedObj>().at(['user', 0]);
156
258
  });
157
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
+
158
285
  it('narrow() refines the cursor type', () => {
159
286
  const c = path<{ value: string | number }>().at(['value']);
160
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
@@ -12,9 +12,10 @@ even if the risky type work later stalls, and the risk zone comes last so a
12
12
  TS wall can't strand finished work behind it.
13
13
 
14
14
  1. **Item 7 — Debugger.** Zero type risk, valuable for developing everything
15
- after it (watching nested state and structured errors live). *current*
15
+ after it (watching nested state and structured errors live). *done*
16
+ (PR #14, released 0.6.0)
16
17
  2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
17
- proven inside `allOf`.
18
+ proven inside `allOf`. ← *current*
18
19
  3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
19
20
  are single-key paths). Doesn't depend on nested constraints; hard
20
21
  prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
@@ -395,3 +396,14 @@ editable.
395
396
 
396
397
  *(Resolved: error path format. Structured `{path, error}[]` with typed
397
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.)*
@@ -2,7 +2,7 @@
2
2
  // turn, but the cycle never exists at runtime.
3
3
  import type { FormDebuggerComponent } from './FormDebugger';
4
4
 
5
- export type FormValueSimple = string | number | bigint | string[] | Set<string> | undefined | null;
5
+ export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
6
6
 
7
7
  export type FormValuesObject = { [k in string]: FormValue };
8
8
 
@@ -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
  >;
@@ -58,6 +58,36 @@ describe('useFormState onSubmit narrowing — inline constraints', () => {
58
58
  });
59
59
  });
60
60
 
61
+ it('accepts a boolean field and narrows it through a marker excluding undefined', () => {
62
+ useFormState({
63
+ initialValues: {
64
+ agreed: undefined as boolean | undefined,
65
+ subscribed: false as boolean,
66
+ },
67
+ constraints: {
68
+ // notEmpty's marker excludes null | undefined | '' — on a
69
+ // boolean | undefined field that refines to plain boolean.
70
+ agreed: notEmpty('agreed'),
71
+ },
72
+ onSubmit: (values) => {
73
+ expectTypeOf(values).toEqualTypeOf<{
74
+ agreed: boolean;
75
+ subscribed: boolean;
76
+ }>();
77
+ },
78
+ });
79
+ });
80
+
81
+ it('rejects a string validator on a boolean field', () => {
82
+ useFormState({
83
+ initialValues: { agreed: false },
84
+ constraints: {
85
+ // @ts-expect-error minLength validates strings, `agreed` is a boolean
86
+ agreed: minLength('agreed', 3),
87
+ },
88
+ });
89
+ });
90
+
61
91
  it('rejects constraints for keys not in the form type', () => {
62
92
  useFormState({
63
93
  initialValues: { a: '' },
@@ -103,6 +133,98 @@ describe('useFormState onSubmit narrowing — pre-built constraints', () => {
103
133
  });
104
134
  });
105
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
+
106
228
  // ---------------------------------------------------------------------------
107
229
  // The recursion probe. Prior explorations of this design narrowed fine on
108
230
  // trivial examples but hit TS recursion limits on realistic form state. This
@@ -126,7 +248,23 @@ type InsuranceQuoteForm = {
126
248
  phone: string | undefined;
127
249
  dateOfBirth: string | undefined;
128
250
  homeAddress: UsAddress;
129
- mailingAddress: UsAddress;
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;
130
268
  employer: string | null;
131
269
  jobTitle: string | null;
132
270
  yearsEmployed: number | null;
@@ -155,6 +293,8 @@ type InsuranceQuoteForm = {
155
293
  discountCodes: string[];
156
294
  referralSource: string | null;
157
295
  notes: string | undefined;
296
+ agreedToTerms: boolean | undefined;
297
+ paperlessBilling: boolean;
158
298
  };
159
299
 
160
300
  describe('useFormState narrowing at realistic scale', () => {
@@ -173,18 +313,22 @@ describe('useFormState narrowing at realistic scale', () => {
173
313
  startDate: notEmpty('startDate'),
174
314
  referralSource: notEmpty('referralSource'),
175
315
  notes: minLength('notes', 10),
316
+ agreedToTerms: notEmpty('agreedToTerms'),
176
317
  },
177
318
  onSubmit: (values) => {
178
319
  // Refined: notEmpty strips null/undefined/'' from the union.
179
320
  expectTypeOf(values.firstName).toEqualTypeOf<string>();
180
321
  expectTypeOf(values.email).toEqualTypeOf<string>();
181
322
  expectTypeOf(values.referralSource).toEqualTypeOf<string>();
323
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
182
324
  // Constrained but non-refining validators leave the type alone.
183
325
  expectTypeOf(values.phone).toEqualTypeOf<string | undefined>();
184
326
  expectTypeOf(values.annualIncomeUsd).toEqualTypeOf<number | null>();
185
327
  expectTypeOf(values.notes).toEqualTypeOf<string | undefined>();
186
328
  // Unconstrained fields — including all nested structure — untouched.
329
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
187
330
  expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
331
+ expectTypeOf(values.mailingAddress).toEqualTypeOf<UsAddress | undefined>();
188
332
  expectTypeOf(values.drivers).toEqualTypeOf<
189
333
  InsuranceQuoteForm['drivers']
190
334
  >();
@@ -201,9 +345,56 @@ describe('useFormState narrowing at realistic scale', () => {
201
345
  expectTypeOf<['homeAddress', 'city']>().toMatchTypeOf<P>();
202
346
  expectTypeOf<['drivers', number, 'incidents', number, 'claimAmountUsd']>().toMatchTypeOf<P>();
203
347
  expectTypeOf<['vehicles', number, 'garagingAddress', 'postalCode']>().toMatchTypeOf<P>();
348
+ expectTypeOf<['agreedToTerms']>().toMatchTypeOf<P>();
204
349
 
205
350
  expectTypeOf<
206
351
  ValueAt<InsuranceQuoteForm, ['drivers', number, 'incidents', number, 'claimAmountUsd']>
207
352
  >().toEqualTypeOf<number | null>();
353
+ expectTypeOf<
354
+ ValueAt<InsuranceQuoteForm, ['agreedToTerms']>
355
+ >().toEqualTypeOf<boolean | undefined>();
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>();
208
399
  });
209
400
  });
@@ -108,6 +108,42 @@ describe('useFormState', () => {
108
108
  });
109
109
  });
110
110
 
111
+ test('a boolean field can be set, validated, and submitted', () => {
112
+ const onSubmit = mock(() => {});
113
+ const { result } = renderHook(() =>
114
+ useFormState({
115
+ initialValues: {
116
+ email: 'a@b.co' as string | undefined,
117
+ agreed: false as boolean,
118
+ },
119
+ constraints: {
120
+ agreed: (val) => (val ? null : 'you must agree to the terms'),
121
+ },
122
+ onSubmit,
123
+ }),
124
+ );
125
+ expect(result.current.values.agreed).toBe(false);
126
+ expect(result.current.errors.agreed).toBe('you must agree to the terms');
127
+ expect(result.current.isValid).toBe(false);
128
+
129
+ act(() => {
130
+ result.current.submit();
131
+ });
132
+ expect(onSubmit).not.toHaveBeenCalled();
133
+
134
+ act(() => {
135
+ result.current.onValueChanges((prev) => ({ ...prev, agreed: true }));
136
+ });
137
+ expect(result.current.errors.agreed).toBeUndefined();
138
+ expect(result.current.isValid).toBe(true);
139
+
140
+ act(() => {
141
+ result.current.submit();
142
+ });
143
+ expect(onSubmit).toHaveBeenCalledTimes(1);
144
+ expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co', agreed: true });
145
+ });
146
+
111
147
  test('a failed submit followed by a fix allows the next submit through', () => {
112
148
  const onSubmit = mock(() => {});
113
149
  const { result } = renderHook(() =>
@@ -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,