@structuralists/scaffolding 0.14.0 → 0.15.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.14.0",
3
+ "version": "0.15.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -63,6 +63,14 @@ type FieldConstraint<F> =
63
63
  | (F extends FormValueList ? ListConstraint<F[number]> : never); // { each: … }
64
64
  ```
65
65
 
66
+ Per field type, that comes out as:
67
+
68
+ | Field type | Legal constraint forms |
69
+ |------------------|---------------------------------------------------------------|
70
+ | scalar leaf | validator, validator array |
71
+ | object section | validator / array (whole-section value), nested `Validations` |
72
+ | list | validator / array (whole-list value), `{ each: spec }` |
73
+
66
74
  The array form is the everyday way to stack validators on a field at the
67
75
  constraint site; a nested spec addresses the fields of an object section;
68
76
  `each` applies a spec to every element of a list. All compose freely:
@@ -126,10 +134,22 @@ const constraints = perField({
126
134
 
127
135
  `perField` is the entry point that produces a `Validations<FormType>`-shaped
128
136
  value while preserving the precise types of each individual validator. It
129
- currently admits only the leaf forms (a validator or validator array per
130
- key) extending it to pre-built nested/`each` specs is plan phase 4
131
- (composition hardening); until then, write structural constraints inline in
132
- the `useFormState` call.
137
+ admits the full recursive grammar leaf validators and arrays, nested
138
+ object specs, `each` specs, freely composed. Its own bound is deliberately
139
+ *shape-only* (validators / validator arrays / objects of more of the same):
140
+ perField never sees the form type, so it can only reject obvious garbage;
141
+ the real check — every key a field, every validator input compatible,
142
+ structural forms only where the field type permits — happens at the
143
+ caller's `satisfies Validations<FormType>`. Consequence: wrong-key and
144
+ wrong-shape errors on a perField-built spec anchor at the `satisfies`, not
145
+ at the offending line inside the call.
146
+
147
+ A pre-built spec is also a legal *nested-spec value*: a
148
+ `perField({...}) satisfies Validations<Section>` object can sit under a
149
+ section key (or an `each` key) of a larger constraints object — building a
150
+ section's spec once and reusing it across sections is the intended
151
+ composition pattern (pinned at ~110-leaf scale in
152
+ `state/useFormState/useFormState.stress.test-d.ts`).
133
153
 
134
154
  ### The precision-preserving ceremony, by call-site shape
135
155
 
@@ -437,6 +457,38 @@ descent into a field.
437
457
  real values onto validators except as opaque type tags — anything else
438
458
  invites consumers to depend on the runtime shape.
439
459
 
460
+ ## Probe suites and the recursion budget
461
+
462
+ The grammar's behavior is pinned by three type-probe tiers (enforced by
463
+ `bun run typecheck`) plus the runtime composition fixture:
464
+
465
+ - `state/validations/types.test-d.ts` — `Refine`/marker semantics at unit
466
+ scale: perField precision, union-member soundness, the widening failure
467
+ modes.
468
+ - `state/useFormState/useFormState.test-d.ts` — the hook boundary at chunky
469
+ (~30-leaf `InsuranceQuoteForm`) scale: the full grammar inline, boundary
470
+ regimes (default `V`, literal `initialValues`, `as const satisfies`),
471
+ union policy, and the negative probes. Authoring gotcha: deep
472
+ wrong-validator-INPUT rejections (TS2322) anchor at the *outermost*
473
+ constraint key, so their `@ts-expect-error` sits on the parent/root key's
474
+ line; wrong-KEY rejections (TS2353) anchor at the leaf.
475
+ - `state/useFormState/useFormState.stress.test-d.ts` — the stress tier
476
+ (~110-leaf `MegaQuoteForm` with the large spec inline twice, a 12-level
477
+ nested-spec ladder, a 4-deep `each` ladder, perField pre-built specs at
478
+ the same scale, deep `Path` bindings). If the recursion budget regresses,
479
+ tsc slows or fails here first.
480
+ - `state/useFormState/useFormState.composition.test.tsx` — the same deep
481
+ mixed composition exercised at *runtime* through the real hook: deep error
482
+ paths, absent-section/null-list skips (and their materialization), deep
483
+ `getFormFieldPropsAt` writes, whole-value leaf validators on structural
484
+ fields, submit gating.
485
+
486
+ Budget baselines live in plan.md's "recursion budget" section. When touching
487
+ the type machinery, run `bunx tsc --noEmit --extendedDiagnostics` and
488
+ compare instantiations against the latest recorded baseline — the failure
489
+ signal is ~10× instantiations or multi-second check time, and regressions
490
+ should be recorded there, not just noticed.
491
+
440
492
  ## Why we're building it this way from the start
441
493
 
442
494
  The whole carrier-type approach falls apart if it gets retrofitted onto
package/src/forms/plan.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Plan: composable, recursive `Validations`
2
2
 
3
- Status: **in progress** — order of operations below. The baseline (flat
4
- `Validations<T>`, one validator per key, shallow `Refine`) is **merged to main**
5
- (PR #12, squashed; released as 0.5.0).
3
+ Status: **complete** — all working-order items and phases below are done.
4
+ The baseline (flat `Validations<T>`, one validator per key, shallow `Refine`)
5
+ merged as PR #12 (released 0.5.0); the recursive grammar landed over phases
6
+ 1–4 (PRs #18, #24, #25, and the composition-hardening close-out). What
7
+ remains in this file is the record: grammar doctrine, decided semantics,
8
+ recorded budgets, and the deliberately-deferred items (per-field meta
9
+ state / stable keys — section 8 — and the open decisions at the end).
6
10
 
7
11
  ## Order of operations
8
12
 
@@ -42,7 +46,7 @@ TS wall can't strand finished work behind it.
42
46
  wrapper-style element shorthands prototyped (`state/bindings/`:
43
47
  `TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
44
48
  baseline note under the recursion budget).
45
- 6. **Type spike, then items 2/3/4** — the recursion risk zone. *current*
49
+ 6. **Type spike, then items 2/3/4** — the recursion risk zone. *done*
46
50
  Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
47
51
  any runtime work; three outcomes (works / slow / intractable) each with a
48
52
  known response. Worst case, everything above still shipped.
@@ -58,6 +62,7 @@ TS wall can't strand finished work behind it.
58
62
  - **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
59
63
  "Phases" below.
60
64
  - **Phase 3 (list `each` runtime): ✅ done** — see "Phases" below.
65
+ - **Phase 4 (composition hardening): ✅ done** — see "Phases" below.
61
66
 
62
67
  ## Goal
63
68
 
@@ -254,6 +259,15 @@ multi-second check time:
254
259
  instantiations, 58,813 types (+1.8% over the pre-phase-3 HEAD at
255
260
  134,330/58,034 — mostly the story's new hook call site and deep
256
261
  `Path`/`ValueAt` bindings)
262
+ - post-phase-4 / **final** (composition hardening: the stress tier ported
263
+ against the real hook — mega form with the spec inline twice, both depth
264
+ ladders, deep bindings — plus perField at the full grammar with its
265
+ probes, and the runtime composition fixture): check 0.92–0.99 s, 172,040
266
+ instantiations, 67,889 types (+25.8% instantiations over post-phase-3 —
267
+ almost entirely the deliberately-heavy stress fixtures, closely matching
268
+ the spike's measured stress-tier cost of ~28k; check time stays
269
+ sub-second, exactly as the spike's headroom predicted). This is the
270
+ standing baseline for future type-machinery work.
257
271
 
258
272
  ## Runtime consequences (can't be dodged)
259
273
 
@@ -343,9 +357,24 @@ tests, story updates where visible, probe ratchet.
343
357
  "object field literally named `each`" disambiguation probe, and the
344
358
  `each: <bare validator>` negative (rejected by TypeScript's weak-type
345
359
  check — an obscure checker rule worth a canary).
346
- 4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
347
- `perField` still the entry point for pre-built specs, docs
348
- (forms/CLAUDE.md) updated to the new grammar.
360
+ 4. **Composition hardening.** ✅ *done* — the closing phase. Deep mixed
361
+ fixtures: the type spike's stress tier ported against the REAL hook
362
+ (`useFormState.stress.test-d.ts` ~110-leaf `MegaQuoteForm` with the
363
+ large applicant spec inline twice, 12-level nested-spec ladder, 4-deep
364
+ `each` ladder, deep `Path` bindings), plus a runtime composition fixture
365
+ through `renderHook` (`useFormState.composition.test.tsx` — deep error
366
+ paths at two list depths, absent-section/null-list skip *and*
367
+ materialization, deep `getFormFieldPropsAt` writes, whole-list leaf
368
+ validators, submit gating end to end). `perField` extended from
369
+ leaf-forms-only to the full recursive grammar: its bound is a loose
370
+ recursive shape (validators / arrays / objects of the same — deliberately
371
+ form-type-agnostic, so the real shape check stays at the caller's
372
+ `satisfies Validations<T>`), pinned at unit scale in
373
+ `validations/types.test-d.ts` and at mega scale (one pre-built applicant
374
+ spec reused across two sections) in the stress file. forms/CLAUDE.md
375
+ brought fully up to the grammar (constraint-forms-per-field-type table,
376
+ perField's new reach, probe-suite map). No runtime machinery changed —
377
+ the deep fixtures flushed out no correctness fixes.
349
378
 
350
379
  ## 5. Split forms into 'form elements' and 'form state' ✅ done
351
380
 
@@ -0,0 +1,342 @@
1
+ import { describe, test, expect, mock } from 'bun:test';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { useFormState } from './useFormState';
4
+ import { errorAt } from './errorAt';
5
+ import { perField } from '../validations/perField';
6
+ import { matches, min, notEmpty } from '../validators/validators';
7
+ import type { Validations } from '../validations/types';
8
+
9
+ // The composition-hardening runtime fixture (plan phase 4): a deep mixed
10
+ // form — lists inside objects inside lists, nested specs inside `each`
11
+ // specs, whole-value leaf validators on structural fields, an absent
12
+ // section and a null list — driven through the REAL useFormState at the
13
+ // hook boundary. walk.test.ts pins each construct's semantics React-free;
14
+ // this file pins that they hold composed, end to end, with the constraints
15
+ // pre-built via perField (the phase-4 entry point for pre-built specs).
16
+
17
+ type UsAddress = {
18
+ line1: string | undefined;
19
+ city: string | undefined;
20
+ postalCode: string | undefined;
21
+ };
22
+
23
+ type Incident = {
24
+ date: string | undefined;
25
+ claimAmountUsd: number | null;
26
+ };
27
+
28
+ type Driver = {
29
+ name: string | undefined;
30
+ incidents: Incident[];
31
+ };
32
+
33
+ type Vehicle = { vin: string | undefined };
34
+
35
+ type Applicant = {
36
+ email: string | undefined;
37
+ homeAddress: UsAddress;
38
+ mailingAddress: UsAddress | undefined;
39
+ drivers: Driver[];
40
+ vehicles: Vehicle[];
41
+ };
42
+
43
+ type QuoteForm = {
44
+ primary: Applicant;
45
+ secondary: Applicant | undefined;
46
+ household: {
47
+ members: Array<{ name: string | undefined }>;
48
+ };
49
+ quotes: Array<{
50
+ coverageType: string | undefined;
51
+ riders: Array<{ code: string | undefined; amountUsd: number | null }>;
52
+ }>;
53
+ pastPolicies: Array<{ insurer: string | undefined }> | null;
54
+ // An object field literally named `each` — the value model, not the
55
+ // constraint's shape, directs interpretation.
56
+ audit: { each: string | undefined };
57
+ agreedToTerms: boolean | undefined;
58
+ };
59
+
60
+ // One applicant spec, built once via perField and reused for both applicant
61
+ // sections — nested specs, each-in-each, arrays at depth, and a whole-list
62
+ // leaf validator on a structural field (leaf and structural forms are
63
+ // mutually exclusive per key: vehicles validates as a value, drivers per
64
+ // element).
65
+ const applicantSpec = perField({
66
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
67
+ homeAddress: {
68
+ city: notEmpty('city'),
69
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
70
+ },
71
+ mailingAddress: {
72
+ city: notEmpty('mailing city'),
73
+ },
74
+ drivers: {
75
+ each: {
76
+ name: notEmpty('name'),
77
+ incidents: {
78
+ each: {
79
+ date: notEmpty('date'),
80
+ claimAmountUsd: min('claimAmountUsd', 0),
81
+ },
82
+ },
83
+ },
84
+ },
85
+ vehicles: [(val: Vehicle[]) => (val.length > 0 ? null : 'at least one vehicle')],
86
+ }) satisfies Validations<Applicant>;
87
+
88
+ const constraints = {
89
+ primary: applicantSpec,
90
+ secondary: applicantSpec,
91
+ household: {
92
+ members: { each: { name: notEmpty('member name') } },
93
+ },
94
+ quotes: {
95
+ each: {
96
+ coverageType: notEmpty('coverageType'),
97
+ riders: {
98
+ each: { code: notEmpty('code'), amountUsd: min('amountUsd', 0) },
99
+ },
100
+ },
101
+ },
102
+ pastPolicies: {
103
+ each: { insurer: notEmpty('insurer') },
104
+ },
105
+ audit: { each: notEmpty('audit each') },
106
+ agreedToTerms: notEmpty('agreedToTerms'),
107
+ } as const satisfies Validations<QuoteForm>;
108
+
109
+ // Mostly-valid deep values with deliberate failures: an each-in-each leaf
110
+ // and its sibling at depth 5, a member name, a rider code at list-in-
111
+ // object-in-list depth, and the `audit.each` disambiguation field. The
112
+ // secondary section is absent and pastPolicies is null — both fully
113
+ // spec'd, both skipped.
114
+ const makeValues = (): QuoteForm => ({
115
+ primary: {
116
+ email: 'ada@lovelace.dev',
117
+ homeAddress: { line1: '1 Main St', city: 'London', postalCode: '12345' },
118
+ mailingAddress: undefined,
119
+ drivers: [
120
+ { name: 'Ada', incidents: [] },
121
+ { name: 'Grace', incidents: [{ date: undefined, claimAmountUsd: -50 }] },
122
+ ],
123
+ vehicles: [{ vin: 'V1' }],
124
+ },
125
+ secondary: undefined,
126
+ household: { members: [{ name: undefined }, { name: 'Linus' }] },
127
+ quotes: [
128
+ {
129
+ coverageType: 'auto',
130
+ riders: [
131
+ { code: 'R1', amountUsd: 10 },
132
+ { code: undefined, amountUsd: null },
133
+ ],
134
+ },
135
+ ],
136
+ pastPolicies: null,
137
+ audit: { each: undefined },
138
+ agreedToTerms: true,
139
+ });
140
+
141
+ const setup = (onSubmit?: (values: QuoteForm) => void) =>
142
+ renderHook(() =>
143
+ useFormState({ initialValues: makeValues(), constraints, onSubmit }),
144
+ );
145
+
146
+ describe('useFormState — deep mixed composition at the hook boundary', () => {
147
+ test('errors carry full structural paths across every grammar construct', () => {
148
+ const { result } = setup();
149
+ // The exact list: constraint-key order at each level, elements in list
150
+ // order, failing nodes only. Absent secondary and null pastPolicies
151
+ // contribute nothing despite full specs; the whole-list vehicles
152
+ // validator and every passing leaf contribute nothing.
153
+ expect(result.current.errors).toEqual([
154
+ {
155
+ path: ['primary', 'drivers', 1, 'incidents', 0, 'date'],
156
+ error: "'date' cannot be empty",
157
+ },
158
+ {
159
+ path: ['primary', 'drivers', 1, 'incidents', 0, 'claimAmountUsd'],
160
+ error: "'claimAmountUsd' must be at least 0",
161
+ },
162
+ {
163
+ path: ['household', 'members', 0, 'name'],
164
+ error: "'member name' cannot be empty",
165
+ },
166
+ {
167
+ path: ['quotes', 0, 'riders', 1, 'code'],
168
+ error: "'code' cannot be empty",
169
+ },
170
+ {
171
+ path: ['audit', 'each'],
172
+ error: "'audit each' cannot be empty",
173
+ },
174
+ ]);
175
+ expect(result.current.isValid).toBe(false);
176
+ });
177
+
178
+ test('errorAt resolves deep numeric paths and stays empty on skipped subtrees', () => {
179
+ const { result } = setup();
180
+ expect(
181
+ errorAt(result.current.errors, ['primary', 'drivers', 1, 'incidents', 0, 'date']),
182
+ ).toBe("'date' cannot be empty");
183
+ expect(errorAt(result.current.errors, ['quotes', 0, 'riders', 1, 'code'])).toBe(
184
+ "'code' cannot be empty",
185
+ );
186
+ // Sibling elements that passed have no entry at their own index.
187
+ expect(
188
+ errorAt(result.current.errors, ['quotes', 0, 'riders', 0, 'code']),
189
+ ).toBeUndefined();
190
+ // The absent section's constrained leaves have no entries.
191
+ expect(errorAt(result.current.errors, ['secondary', 'email'])).toBeUndefined();
192
+ });
193
+
194
+ test('a deep fix through getFormFieldPropsAt clears exactly that entry', () => {
195
+ const { result } = setup();
196
+
197
+ act(() => {
198
+ result.current
199
+ .getFormFieldPropsAt(['primary', 'drivers', 1, 'incidents', 0, 'date'])
200
+ .onChange('2026-01-01');
201
+ });
202
+
203
+ expect(
204
+ errorAt(result.current.errors, ['primary', 'drivers', 1, 'incidents', 0, 'date']),
205
+ ).toBeUndefined();
206
+ // The failing sibling leaf in the same element is untouched …
207
+ expect(
208
+ errorAt(result.current.errors, [
209
+ 'primary',
210
+ 'drivers',
211
+ 1,
212
+ 'incidents',
213
+ 0,
214
+ 'claimAmountUsd',
215
+ ]),
216
+ ).toBe("'claimAmountUsd' must be at least 0");
217
+ // … and the write only cloned the spine: the other driver keeps identity.
218
+ expect(result.current.values.primary.drivers[0]).toEqual({
219
+ name: 'Ada',
220
+ incidents: [],
221
+ });
222
+ });
223
+
224
+ test('a whole-list leaf validator on a structural field runs against the list value', () => {
225
+ const { result } = setup();
226
+ expect(errorAt(result.current.errors, ['primary', 'vehicles'])).toBeUndefined();
227
+
228
+ act(() => {
229
+ result.current.getFormFieldPropsAt(['primary', 'vehicles']).onChange([]);
230
+ });
231
+
232
+ expect(errorAt(result.current.errors, ['primary', 'vehicles'])).toBe(
233
+ 'at least one vehicle',
234
+ );
235
+ });
236
+
237
+ test('materializing an absent section brings its whole spec to life', () => {
238
+ const { result } = setup();
239
+
240
+ act(() => {
241
+ result.current.onValueChanges((prev) => ({
242
+ ...prev,
243
+ secondary: {
244
+ email: undefined,
245
+ homeAddress: { line1: undefined, city: undefined, postalCode: undefined },
246
+ mailingAddress: undefined,
247
+ drivers: [{ name: undefined, incidents: [] }],
248
+ vehicles: [],
249
+ },
250
+ }));
251
+ });
252
+
253
+ expect(errorAt(result.current.errors, ['secondary', 'email'])).toBe(
254
+ "'email' cannot be empty",
255
+ );
256
+ expect(errorAt(result.current.errors, ['secondary', 'homeAddress', 'city'])).toBe(
257
+ "'city' cannot be empty",
258
+ );
259
+ expect(
260
+ errorAt(result.current.errors, ['secondary', 'drivers', 0, 'name']),
261
+ ).toBe("'name' cannot be empty");
262
+ expect(errorAt(result.current.errors, ['secondary', 'vehicles'])).toBe(
263
+ 'at least one vehicle',
264
+ );
265
+ // The still-absent nested section inside the materialized one stays
266
+ // skipped.
267
+ expect(
268
+ errorAt(result.current.errors, ['secondary', 'mailingAddress', 'city']),
269
+ ).toBeUndefined();
270
+ });
271
+
272
+ test('a null list wakes up the same way', () => {
273
+ const { result } = setup();
274
+ expect(errorAt(result.current.errors, ['pastPolicies', 0, 'insurer'])).toBeUndefined();
275
+
276
+ act(() => {
277
+ result.current.onValueChanges((prev) => ({
278
+ ...prev,
279
+ pastPolicies: [{ insurer: 'Acme' }, { insurer: undefined }],
280
+ }));
281
+ });
282
+
283
+ expect(errorAt(result.current.errors, ['pastPolicies', 0, 'insurer'])).toBeUndefined();
284
+ expect(errorAt(result.current.errors, ['pastPolicies', 1, 'insurer'])).toBe(
285
+ "'insurer' cannot be empty",
286
+ );
287
+ });
288
+
289
+ test('submit is gated on the deep composition and passes the current values through', () => {
290
+ let submitted: QuoteForm | undefined;
291
+ const onSubmit = mock((values: QuoteForm) => {
292
+ submitted = values;
293
+ });
294
+ const { result } = setup(onSubmit);
295
+
296
+ act(() => {
297
+ result.current.submit();
298
+ });
299
+ expect(onSubmit).not.toHaveBeenCalled();
300
+ expect(result.current.submitAttempted).toBe(true);
301
+
302
+ // Fix every failing node, one grammar construct at a time.
303
+ act(() => {
304
+ result.current
305
+ .getFormFieldPropsAt(['primary', 'drivers', 1, 'incidents', 0, 'date'])
306
+ .onChange('2026-01-01');
307
+ });
308
+ act(() => {
309
+ result.current
310
+ .getFormFieldPropsAt(['primary', 'drivers', 1, 'incidents', 0, 'claimAmountUsd'])
311
+ .onChange(500);
312
+ });
313
+ act(() => {
314
+ result.current
315
+ .getFormFieldPropsAt(['household', 'members', 0, 'name'])
316
+ .onChange('Margaret');
317
+ });
318
+ act(() => {
319
+ result.current
320
+ .getFormFieldPropsAt(['quotes', 0, 'riders', 1, 'code'])
321
+ .onChange('R2');
322
+ });
323
+ act(() => {
324
+ result.current.getFormFieldPropsAt(['audit', 'each']).onChange('reviewed');
325
+ });
326
+
327
+ expect(result.current.errors).toEqual([]);
328
+ expect(result.current.isValid).toBe(true);
329
+
330
+ act(() => {
331
+ result.current.submit();
332
+ });
333
+ expect(onSubmit).toHaveBeenCalledTimes(1);
334
+ expect(submitted?.primary.drivers[1].incidents[0]).toEqual({
335
+ date: '2026-01-01',
336
+ claimAmountUsd: 500,
337
+ });
338
+ expect(submitted?.quotes[0].riders[1].code).toBe('R2');
339
+ expect(submitted?.secondary).toBeUndefined();
340
+ expect(submitted?.pastPolicies).toBeNull();
341
+ });
342
+ });
@@ -0,0 +1,451 @@
1
+ import { describe, it, expectTypeOf } from 'vitest';
2
+ import { useFormState } from './useFormState';
3
+ import { perField } from '../validations/perField';
4
+ import { matches, min, minLength, notEmpty } from '../validators/validators';
5
+ import type { FormFieldProps } from './types';
6
+ import type { Refine, Validations } from '../validations/types';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // The stress tier (plan phase 4, composition hardening) — the phase-2 type
10
+ // spike's stress fixtures, pinned against the REAL hook. Where
11
+ // useFormState.test-d.ts probes the grammar at chunky (~30-leaf) scale, this
12
+ // file pushes past it to hold the measured headroom: a ~110-leaf form at 5–6
13
+ // levels with the large applicant spec written inline twice, the same spec
14
+ // pre-built ONCE via perField and reused for both applicant sections (the
15
+ // pre-built entry point at realistic scale), a 12-level nested-object spec
16
+ // ladder, a 4-deep list-in-list `each` ladder, and deep Path bindings on the
17
+ // mega form. If the recursion budget regresses, tsc slows or fails here
18
+ // first — watch the instantiation count in the plan's recursion-budget
19
+ // section, not just success.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ type UsAddress = {
23
+ line1: string | undefined;
24
+ line2: string | undefined;
25
+ city: string | undefined;
26
+ state: string | undefined;
27
+ postalCode: string | undefined;
28
+ };
29
+
30
+ type Incident = {
31
+ date: string | undefined;
32
+ kind: string | undefined;
33
+ claimAmountUsd: number | null;
34
+ };
35
+
36
+ type Driver = {
37
+ name: string | undefined;
38
+ licenseNumber: string | undefined;
39
+ licenseState: string | undefined;
40
+ incidents: Incident[];
41
+ };
42
+
43
+ type Vehicle = {
44
+ vin: string | undefined;
45
+ year: number | undefined;
46
+ make: string | undefined;
47
+ model: string | undefined;
48
+ primaryDriverName: string | undefined;
49
+ garagingAddress: UsAddress;
50
+ };
51
+
52
+ type Applicant = {
53
+ firstName: string | undefined;
54
+ lastName: string | undefined;
55
+ email: string | undefined;
56
+ phone: string | undefined;
57
+ dateOfBirth: string | undefined;
58
+ employer: string | null;
59
+ jobTitle: string | null;
60
+ yearsEmployed: number | null;
61
+ annualIncomeUsd: number | null;
62
+ homeAddress: UsAddress;
63
+ mailingAddress: UsAddress | undefined;
64
+ drivers: Driver[];
65
+ vehicles: Vehicle[];
66
+ };
67
+
68
+ type MegaQuoteForm = {
69
+ primary: Applicant;
70
+ secondary: Applicant | undefined;
71
+ household: {
72
+ address: UsAddress;
73
+ members: Array<{
74
+ name: string | undefined;
75
+ relation: string | undefined;
76
+ dateOfBirth: string | undefined;
77
+ }>;
78
+ };
79
+ quotes: Array<{
80
+ coverageType: string | undefined;
81
+ deductibleUsd: number | undefined;
82
+ startDate: string | undefined;
83
+ riders: Array<{
84
+ code: string | undefined;
85
+ amountUsd: number | null;
86
+ }>;
87
+ }>;
88
+ referralSource: string | null;
89
+ notes: string | undefined;
90
+ agreedToTerms: boolean | undefined;
91
+ paperlessBilling: boolean;
92
+ };
93
+
94
+ describe('useFormState stress: ~110-leaf form, large inline spec written twice', () => {
95
+ it('composes and refines with the applicant spec duplicated inline', () => {
96
+ useFormState({
97
+ initialValues: {} as MegaQuoteForm,
98
+ constraints: {
99
+ primary: {
100
+ firstName: notEmpty('firstName'),
101
+ lastName: [notEmpty('lastName')],
102
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
103
+ phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
104
+ dateOfBirth: notEmpty('dateOfBirth'),
105
+ yearsEmployed: min('yearsEmployed', 0),
106
+ annualIncomeUsd: min('annualIncomeUsd', 0),
107
+ homeAddress: {
108
+ line1: notEmpty('line1'),
109
+ city: notEmpty('city'),
110
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
111
+ },
112
+ mailingAddress: {
113
+ city: notEmpty('mailing city'),
114
+ },
115
+ drivers: {
116
+ each: {
117
+ name: notEmpty('name'),
118
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
119
+ incidents: {
120
+ each: {
121
+ date: notEmpty('date'),
122
+ claimAmountUsd: min('claimAmountUsd', 0),
123
+ },
124
+ },
125
+ },
126
+ },
127
+ vehicles: {
128
+ each: {
129
+ vin: [notEmpty('vin')],
130
+ year: min('year', 1900),
131
+ garagingAddress: {
132
+ line1: notEmpty('garaging line1'),
133
+ postalCode: [notEmpty('garaging postalCode')],
134
+ },
135
+ },
136
+ },
137
+ },
138
+ // The same large spec again, on the nullable secondary applicant —
139
+ // simulates a real page where big sections each get full specs.
140
+ secondary: {
141
+ firstName: notEmpty('firstName'),
142
+ lastName: [notEmpty('lastName')],
143
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
144
+ phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
145
+ dateOfBirth: notEmpty('dateOfBirth'),
146
+ yearsEmployed: min('yearsEmployed', 0),
147
+ annualIncomeUsd: min('annualIncomeUsd', 0),
148
+ homeAddress: {
149
+ line1: notEmpty('line1'),
150
+ city: notEmpty('city'),
151
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
152
+ },
153
+ mailingAddress: {
154
+ city: notEmpty('mailing city'),
155
+ },
156
+ drivers: {
157
+ each: {
158
+ name: notEmpty('name'),
159
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
160
+ incidents: {
161
+ each: {
162
+ date: notEmpty('date'),
163
+ claimAmountUsd: min('claimAmountUsd', 0),
164
+ },
165
+ },
166
+ },
167
+ },
168
+ vehicles: {
169
+ each: {
170
+ vin: [notEmpty('vin')],
171
+ year: min('year', 1900),
172
+ garagingAddress: {
173
+ line1: notEmpty('garaging line1'),
174
+ postalCode: [notEmpty('garaging postalCode')],
175
+ },
176
+ },
177
+ },
178
+ },
179
+ household: {
180
+ address: {
181
+ city: notEmpty('household city'),
182
+ },
183
+ members: {
184
+ each: {
185
+ name: notEmpty('member name'),
186
+ dateOfBirth: notEmpty('member dateOfBirth'),
187
+ },
188
+ },
189
+ },
190
+ quotes: {
191
+ each: {
192
+ coverageType: notEmpty('coverageType'),
193
+ deductibleUsd: min('deductibleUsd', 0),
194
+ startDate: notEmpty('startDate'),
195
+ riders: {
196
+ each: {
197
+ code: notEmpty('code'),
198
+ amountUsd: min('amountUsd', 0),
199
+ },
200
+ },
201
+ },
202
+ },
203
+ referralSource: notEmpty('referralSource'),
204
+ agreedToTerms: notEmpty('agreedToTerms'),
205
+ },
206
+ onSubmit: (values) => {
207
+ // Depth 5–6 refinements on both applicant sections.
208
+ expectTypeOf(values.primary.email).toEqualTypeOf<string>();
209
+ expectTypeOf(values.primary.homeAddress.postalCode).toEqualTypeOf<string>();
210
+ expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
211
+ expectTypeOf(values.primary.vehicles[0].garagingAddress.line1).toEqualTypeOf<string>();
212
+ expectTypeOf(values.primary.drivers[0].licenseState).toEqualTypeOf<string | undefined>();
213
+ expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
214
+ expectTypeOf(values.secondary?.drivers[0].incidents[0].date).toEqualTypeOf<
215
+ string | undefined
216
+ >();
217
+ expectTypeOf(values.household.members[0].name).toEqualTypeOf<string>();
218
+ expectTypeOf(values.quotes[0].riders[0].code).toEqualTypeOf<string>();
219
+ expectTypeOf(values.quotes[0].riders[0].amountUsd).toEqualTypeOf<number | null>();
220
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
221
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
222
+ },
223
+ });
224
+ });
225
+
226
+ it('Path machinery holds at mega scale: deep bindings on the hook result', () => {
227
+ const form = useFormState({ initialValues: {} as MegaQuoteForm });
228
+
229
+ expectTypeOf(
230
+ form.getFormFieldPropsAt(['primary', 'drivers', 0, 'incidents', 0, 'date']),
231
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
232
+ expectTypeOf(
233
+ form.getFormFieldPropsAt(['secondary', 'vehicles', 0, 'garagingAddress', 'postalCode']),
234
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
235
+ expectTypeOf(
236
+ form.getFormFieldPropsAt(['quotes', 0, 'riders', 0, 'amountUsd']),
237
+ ).toEqualTypeOf<FormFieldProps<number | null>>();
238
+ expectTypeOf(
239
+ form.getFormFieldPropsAt(['household', 'members', 0, 'relation']),
240
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
241
+
242
+ // @ts-expect-error 'drvers' is not a field of the primary applicant
243
+ form.getFormFieldPropsAt(['primary', 'drvers', 0, 'name']);
244
+ });
245
+ });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // perField at realistic scale (plan phase 4): the pre-built entry point on
249
+ // the full recursive grammar — one applicant spec built once, reused for
250
+ // both applicant sections, mixing each-in-each, nested-in-each, and a
251
+ // whole-list leaf validator on a structural field. Before this phase,
252
+ // perField was only ever exercised on a 3-key flat form.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe('useFormState stress: perField pre-built specs at mega scale', () => {
256
+ it('preserves markers through a perField applicant spec reused across sections', () => {
257
+ const applicantSpec = perField({
258
+ firstName: notEmpty('firstName'),
259
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
260
+ yearsEmployed: min('yearsEmployed', 0),
261
+ homeAddress: {
262
+ city: notEmpty('city'),
263
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
264
+ },
265
+ mailingAddress: {
266
+ city: notEmpty('mailing city'),
267
+ },
268
+ drivers: {
269
+ each: {
270
+ name: notEmpty('name'),
271
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
272
+ incidents: {
273
+ each: {
274
+ date: notEmpty('date'),
275
+ claimAmountUsd: min('claimAmountUsd', 0),
276
+ },
277
+ },
278
+ },
279
+ },
280
+ // Whole-list leaf validator on a structural field: leaf and structural
281
+ // forms are mutually exclusive per key, so vehicles takes the
282
+ // list-as-a-value form while drivers takes `each`.
283
+ vehicles: [
284
+ (val: Vehicle[]) => (val.length > 0 ? null : 'at least one vehicle'),
285
+ ],
286
+ }) satisfies Validations<Applicant>;
287
+
288
+ const constraints = {
289
+ primary: applicantSpec,
290
+ secondary: applicantSpec,
291
+ household: {
292
+ members: {
293
+ each: { name: notEmpty('member name') },
294
+ },
295
+ },
296
+ agreedToTerms: notEmpty('agreedToTerms'),
297
+ } as const satisfies Validations<MegaQuoteForm>;
298
+
299
+ // Direct Refine application on the composed pre-built object.
300
+ type Submit = Refine<MegaQuoteForm, typeof constraints>;
301
+ expectTypeOf<Submit['primary']['email']>().toEqualTypeOf<string>();
302
+ expectTypeOf<Submit['primary']['homeAddress']['postalCode']>().toEqualTypeOf<string>();
303
+ expectTypeOf<
304
+ Submit['primary']['drivers'][number]['incidents'][number]['date']
305
+ >().toEqualTypeOf<string>();
306
+ expectTypeOf<Submit['primary']['vehicles']>().toEqualTypeOf<Vehicle[]>();
307
+ expectTypeOf<Submit['secondary']>().toEqualTypeOf<
308
+ Refine<Applicant, typeof applicantSpec> | undefined
309
+ >();
310
+ expectTypeOf<Submit['household']['members'][number]['name']>().toEqualTypeOf<string>();
311
+ expectTypeOf<Submit['quotes']>().toEqualTypeOf<MegaQuoteForm['quotes']>();
312
+
313
+ // And through the hook boundary.
314
+ useFormState({
315
+ initialValues: {} as MegaQuoteForm,
316
+ constraints,
317
+ onSubmit: (values) => {
318
+ expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
319
+ expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
320
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
321
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
322
+ },
323
+ });
324
+ });
325
+ });
326
+
327
+ // --- Object-depth ladder: specs 12 levels deep --------------------------------
328
+
329
+ type L12 = { leaf: string | undefined; tag: string | undefined };
330
+ type L11 = { next: L12; tag: string | undefined };
331
+ type L10 = { next: L11; tag: string | undefined };
332
+ type L9 = { next: L10; tag: string | undefined };
333
+ type L8 = { next: L9; tag: string | undefined };
334
+ type L7 = { next: L8; tag: string | undefined };
335
+ type L6 = { next: L7; tag: string | undefined };
336
+ type L5 = { next: L6; tag: string | undefined };
337
+ type L4 = { next: L5; tag: string | undefined };
338
+ type L3 = { next: L4; tag: string | undefined };
339
+ type L2 = { next: L3; tag: string | undefined };
340
+ type L1 = { next: L2; tag: string | undefined };
341
+ type DeepObjectForm = { root: L1 };
342
+
343
+ describe('useFormState stress: 12-level nested-object spec ladder', () => {
344
+ it('refines a leaf 13 levels down, with a validator at every level', () => {
345
+ useFormState({
346
+ initialValues: {} as DeepObjectForm,
347
+ constraints: {
348
+ root: {
349
+ tag: notEmpty('t1'),
350
+ next: {
351
+ tag: notEmpty('t2'),
352
+ next: {
353
+ tag: notEmpty('t3'),
354
+ next: {
355
+ tag: notEmpty('t4'),
356
+ next: {
357
+ tag: notEmpty('t5'),
358
+ next: {
359
+ tag: notEmpty('t6'),
360
+ next: {
361
+ tag: notEmpty('t7'),
362
+ next: {
363
+ tag: notEmpty('t8'),
364
+ next: {
365
+ tag: notEmpty('t9'),
366
+ next: {
367
+ tag: notEmpty('t10'),
368
+ next: {
369
+ tag: notEmpty('t11'),
370
+ next: {
371
+ tag: notEmpty('t12'),
372
+ leaf: [notEmpty('leaf'), minLength('leaf', 2)],
373
+ },
374
+ },
375
+ },
376
+ },
377
+ },
378
+ },
379
+ },
380
+ },
381
+ },
382
+ },
383
+ },
384
+ },
385
+ },
386
+ onSubmit: (values) => {
387
+ expectTypeOf(
388
+ values.root.next.next.next.next.next.next.next.next.next.next.next.leaf,
389
+ ).toEqualTypeOf<string>();
390
+ expectTypeOf(
391
+ values.root.next.next.next.next.next.next.next.next.next.next.next.tag,
392
+ ).toEqualTypeOf<string>();
393
+ expectTypeOf(values.root.tag).toEqualTypeOf<string>();
394
+ },
395
+ });
396
+ });
397
+ });
398
+
399
+ // --- List-depth ladder: `each` 4 levels deep ----------------------------------
400
+
401
+ type DeepListForm = {
402
+ xs: Array<{
403
+ label: string | undefined;
404
+ ys: Array<{
405
+ label: string | undefined;
406
+ zs: Array<{
407
+ label: string | undefined;
408
+ ws: Array<{
409
+ v: string | undefined;
410
+ n: number | null;
411
+ }>;
412
+ }>;
413
+ }>;
414
+ }>;
415
+ };
416
+
417
+ describe('useFormState stress: 4-deep list-in-list each ladder', () => {
418
+ it('refines through four nested each specs', () => {
419
+ useFormState({
420
+ initialValues: {} as DeepListForm,
421
+ constraints: {
422
+ xs: {
423
+ each: {
424
+ label: notEmpty('x label'),
425
+ ys: {
426
+ each: {
427
+ label: notEmpty('y label'),
428
+ zs: {
429
+ each: {
430
+ label: notEmpty('z label'),
431
+ ws: {
432
+ each: {
433
+ v: notEmpty('v'),
434
+ n: min('n', 0),
435
+ },
436
+ },
437
+ },
438
+ },
439
+ },
440
+ },
441
+ },
442
+ },
443
+ },
444
+ onSubmit: (values) => {
445
+ expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].v).toEqualTypeOf<string>();
446
+ expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].n).toEqualTypeOf<number | null>();
447
+ expectTypeOf(values.xs[0].ys[0].zs[0].label).toEqualTypeOf<string>();
448
+ },
449
+ });
450
+ });
451
+ });
@@ -1,14 +1,25 @@
1
+ // The loose, form-type-agnostic shape of one constraint: a validator, a
2
+ // validator array, or an object of more of the same — which covers both
3
+ // nested specs and `{ each: … }` (an `each` value is itself a spec object).
4
+ // `(val: never) => …` admits any validator input contravariantly. This bound
5
+ // only rejects obvious garbage (strings, numbers, null values); the real
6
+ // shape check — every key a field, every validator's input compatible,
7
+ // structural forms only where the field type permits — happens at the
8
+ // caller's `satisfies Validations<FormType>`, which is where the form type
9
+ // first enters the picture.
10
+ type LooseFieldConstraint =
11
+ | ((val: never) => string | null)
12
+ | readonly ((val: never) => string | null)[]
13
+ | { readonly [key: string]: LooseFieldConstraint };
14
+
1
15
  // Aggregation entry point for building a `Validations<T>`-shaped constraints
2
- // object. Runtime identity; the value is in the type: the `const` type
3
- // parameter freezes each validator's precise type (including `Refinement`
4
- // markers) instead of letting function types widen. Callers still apply
5
- // `as const satisfies Validations<FormType>` to shape-check against their
6
- // form type without losing that precision see src/forms/CLAUDE.md.
7
- export const perField = <
8
- const V extends Record<
9
- string,
10
- ((val: never) => string | null) | readonly ((val: never) => string | null)[]
11
- >,
12
- >(
16
+ // object the full recursive grammar (leaf validators and arrays, nested
17
+ // object specs, list `each` specs, freely composed). Runtime identity; the
18
+ // value is in the type: the `const` type parameter freezes each validator's
19
+ // precise type (including `Refinement` markers) instead of letting function
20
+ // types widen. Callers still apply `satisfies Validations<FormType>` to
21
+ // shape-check against their form type without losing that precision — see
22
+ // src/forms/CLAUDE.md.
23
+ export const perField = <const V extends Record<string, LooseFieldConstraint>>(
13
24
  validations: V,
14
25
  ): V => validations;
@@ -205,6 +205,114 @@ describe('Refine<T, V> — union-typed members inside arrays', () => {
205
205
  });
206
206
  });
207
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // perField at the recursive grammar (plan phase 4): the pre-built-spec entry
210
+ // point admits the full grammar — nested object specs, list `each` specs,
211
+ // arrays at every level — and preserves refinement markers through all of it.
212
+ // perField's own bound is shape-only (validators / arrays / objects of the
213
+ // same); the form-type shape check happens at `satisfies Validations<T>`.
214
+ // ---------------------------------------------------------------------------
215
+
216
+ type NestedFormType = {
217
+ email: string | undefined;
218
+ homeAddress: {
219
+ city: string | undefined;
220
+ postalCode: string | undefined;
221
+ };
222
+ mailingAddress: { city: string | undefined } | undefined;
223
+ drivers: Array<{
224
+ name: string | undefined;
225
+ incidents: Array<{ date: string | undefined }>;
226
+ }>;
227
+ };
228
+
229
+ describe('perField — recursive grammar', () => {
230
+ it('preserves markers through nested specs and each-in-each specs', () => {
231
+ const constraints = perField({
232
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
233
+ homeAddress: {
234
+ city: notEmpty('city'),
235
+ postalCode: [notEmpty('postalCode'), minLength('postalCode', 5)],
236
+ },
237
+ mailingAddress: {
238
+ city: notEmpty('mailing city'),
239
+ },
240
+ drivers: {
241
+ each: {
242
+ name: notEmpty('name'),
243
+ incidents: { each: { date: notEmpty('date') } },
244
+ },
245
+ },
246
+ }) satisfies Validations<NestedFormType>;
247
+
248
+ type Result = Refine<NestedFormType, typeof constraints>;
249
+ expectTypeOf<Result['email']>().toEqualTypeOf<string>();
250
+ expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
251
+ expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<string>();
252
+ // Nullability survives around the refined interior of a nullable section.
253
+ expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
254
+ { city: string } | undefined
255
+ >();
256
+ expectTypeOf<Result['drivers'][number]['name']>().toEqualTypeOf<string>();
257
+ expectTypeOf<
258
+ Result['drivers'][number]['incidents'][number]['date']
259
+ >().toEqualTypeOf<string>();
260
+ });
261
+
262
+ it('a pre-built section spec composes as a nested-spec value', () => {
263
+ // Building one section's spec via perField and placing it under the
264
+ // section key in a larger constraints object — the reuse shape phase 4
265
+ // exists to keep working.
266
+ const addressSpec = perField({
267
+ city: notEmpty('city'),
268
+ }) satisfies Validations<NestedFormType['homeAddress']>;
269
+
270
+ const constraints = {
271
+ homeAddress: addressSpec,
272
+ mailingAddress: addressSpec,
273
+ } as const satisfies Validations<NestedFormType>;
274
+
275
+ type Result = Refine<NestedFormType, typeof constraints>;
276
+ expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
277
+ expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<
278
+ string | undefined
279
+ >();
280
+ expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
281
+ { city: string } | undefined
282
+ >();
283
+ });
284
+
285
+ it('rejects non-constraint values at the perField call itself', () => {
286
+ perField({
287
+ // @ts-expect-error a string is not a validator, array, or spec
288
+ email: 'not a validator',
289
+ });
290
+ perField({
291
+ // @ts-expect-error garbage stays rejected at depth (the TS2322
292
+ // anchors at the parent key, same as deep validator-input mismatches)
293
+ homeAddress: {
294
+ city: 42,
295
+ },
296
+ });
297
+ });
298
+
299
+ it('form-type shape errors surface at the satisfies check', () => {
300
+ // perField is form-type-agnostic, so a wrong key passes its bound and
301
+ // is caught where the form type first appears.
302
+ const wrongKey = perField({
303
+ homeAddress: { cityy: notEmpty('cityy') },
304
+ });
305
+ // @ts-expect-error 'cityy' is not a field of homeAddress
306
+ wrongKey satisfies Validations<NestedFormType>;
307
+
308
+ const eachOnObject = perField({
309
+ homeAddress: { each: { city: notEmpty('city') } },
310
+ });
311
+ // @ts-expect-error an `each` spec is not legal on an object field
312
+ eachOnObject satisfies Validations<NestedFormType>;
313
+ });
314
+ });
315
+
208
316
  describe('Refine<T, V> — union-typed single constraints (conditional pick)', () => {
209
317
  it('narrows a conditionally-picked validator to the union of branch results', () => {
210
318
  // Only ONE branch runs at runtime, so the sound result is the UNION of