@structuralists/scaffolding 0.11.0 → 0.12.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.
@@ -310,6 +310,12 @@ type InsuranceQuoteForm = {
310
310
  referralSource: string | null;
311
311
  notes: string | undefined;
312
312
  agreedToTerms: boolean | undefined;
313
+ // Disambiguation probe: an object field owning a key literally named
314
+ // `each` must still be treated as a nested object spec (directed by T).
315
+ audit: {
316
+ each: string | undefined;
317
+ reviewedBy: string | undefined;
318
+ };
313
319
  paperlessBilling: boolean;
314
320
  };
315
321
 
@@ -547,3 +553,433 @@ describe('useFormState narrowing at realistic scale', () => {
547
553
  >().toEqualTypeOf<string | null | undefined>();
548
554
  });
549
555
  });
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // The recursive grammar (plan phase 2, grammar proven whole by the phase-2
559
+ // type spike): nested object specs, list `each` specs, validator arrays at
560
+ // every level, and the recursive `Refine` — exercised inline at the hook
561
+ // boundary on the chunky form. Ported from the spike's probe suite; the
562
+ // spike's two binding adjustments (value-model-first branch order in
563
+ // `RefineField`, the `Refine` identity gate) are what these probes pin.
564
+ // ---------------------------------------------------------------------------
565
+
566
+ describe('useFormState — full recursive grammar at InsuranceQuoteForm scale, inline', () => {
567
+ it('composes leaf/array/nested/each constraints and refines at every depth', () => {
568
+ useFormState({
569
+ initialValues: {} as InsuranceQuoteForm,
570
+ constraints: {
571
+ // -- root leaves, same mix as the flat probe above
572
+ firstName: notEmpty('firstName'),
573
+ lastName: [notEmpty('lastName')],
574
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
575
+ phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
576
+ dateOfBirth: allOf(
577
+ notEmpty('dateOfBirth'),
578
+ matches('dateOfBirth', /^\d{4}-\d{2}-\d{2}$/, 'an ISO date'),
579
+ ),
580
+ yearsEmployed: min('yearsEmployed', 0),
581
+ coverageType: [
582
+ notEmpty('coverageType'),
583
+ (val) => {
584
+ expectTypeOf(val).toEqualTypeOf<string | undefined>();
585
+ return null;
586
+ },
587
+ ],
588
+ agreedToTerms: notEmpty('agreedToTerms'),
589
+ // -- nested object spec (depth 2), validators + arrays + bare arrow
590
+ homeAddress: {
591
+ city: notEmpty('city'),
592
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
593
+ state: (val) => {
594
+ expectTypeOf(val).toEqualTypeOf<string | undefined>();
595
+ return null;
596
+ },
597
+ },
598
+ // -- nested object spec on a NULLABLE section
599
+ mailingAddress: {
600
+ city: notEmpty('mailing city'),
601
+ },
602
+ coApplicant: {
603
+ firstName: notEmpty('coApplicant firstName'),
604
+ },
605
+ // -- list `each` spec with a nested list `each` inside (the full
606
+ // composition case from the plan), plus arrays and bare arrows at
607
+ // both list depths
608
+ drivers: {
609
+ each: {
610
+ name: notEmpty('name'),
611
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
612
+ licenseState: (val) => {
613
+ expectTypeOf(val).toEqualTypeOf<string | undefined>();
614
+ return null;
615
+ },
616
+ incidents: {
617
+ each: {
618
+ date: notEmpty('date'),
619
+ kind: (val) => {
620
+ expectTypeOf(val).toEqualTypeOf<string | undefined>();
621
+ return null;
622
+ },
623
+ claimAmountUsd: min('claimAmountUsd', 0),
624
+ },
625
+ },
626
+ },
627
+ },
628
+ // -- object-in-list: each spec whose element has a nested object spec
629
+ vehicles: {
630
+ each: {
631
+ vin: [notEmpty('vin')],
632
+ year: min('year', 1900),
633
+ garagingAddress: {
634
+ line1: notEmpty('garaging line1'),
635
+ postalCode: [notEmpty('garaging postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
636
+ },
637
+ },
638
+ },
639
+ // -- each spec on a NULLABLE list
640
+ pastPolicies: {
641
+ each: {
642
+ insurer: notEmpty('insurer'),
643
+ },
644
+ },
645
+ // -- whole-list leaf validator on a list field (leaf forms stay legal
646
+ // for structural fields)
647
+ discountCodes: (val) => {
648
+ expectTypeOf(val).toEqualTypeOf<string[]>();
649
+ return null;
650
+ },
651
+ // -- object field owning a key named `each`: interpreted as a nested
652
+ // object spec because T directs, not the constraint's shape
653
+ audit: {
654
+ each: notEmpty('audit each'),
655
+ },
656
+ },
657
+ onSubmit: (values) => {
658
+ // Root leaves refine exactly as on the flat grammar.
659
+ expectTypeOf(values.firstName).toEqualTypeOf<string>();
660
+ expectTypeOf(values.lastName).toEqualTypeOf<string>();
661
+ expectTypeOf(values.email).toEqualTypeOf<string>();
662
+ expectTypeOf(values.dateOfBirth).toEqualTypeOf<string>();
663
+ expectTypeOf(values.coverageType).toEqualTypeOf<string>();
664
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
665
+ expectTypeOf(values.phone).toEqualTypeOf<string | undefined>();
666
+ expectTypeOf(values.yearsEmployed).toEqualTypeOf<number | null>();
667
+
668
+ // Nested object spec: constrained leaves refine, siblings pass through.
669
+ expectTypeOf(values.homeAddress.city).toEqualTypeOf<string>();
670
+ expectTypeOf(values.homeAddress.postalCode).toEqualTypeOf<string>();
671
+ expectTypeOf(values.homeAddress.state).toEqualTypeOf<string | undefined>();
672
+ expectTypeOf(values.homeAddress.line1).toEqualTypeOf<string | undefined>();
673
+
674
+ // Nullable section: the section stays optional, its constrained leaf
675
+ // refines inside the present branch.
676
+ expectTypeOf(values.mailingAddress?.city).toEqualTypeOf<string | undefined>();
677
+ expectTypeOf(values.mailingAddress?.line1).toEqualTypeOf<string | undefined>();
678
+ expectTypeOf(values.coApplicant?.firstName).toEqualTypeOf<string | undefined>();
679
+ expectTypeOf(values.coApplicant?.sharesResidence).toEqualTypeOf<boolean | undefined>();
680
+
681
+ // each: refined element type flows through Array<...>.
682
+ expectTypeOf(values.drivers[0].name).toEqualTypeOf<string>();
683
+ expectTypeOf(values.drivers[0].licenseNumber).toEqualTypeOf<string>();
684
+ expectTypeOf(values.drivers[0].licenseState).toEqualTypeOf<string | undefined>();
685
+
686
+ // list-in-list: refinement reaches depth 4.
687
+ expectTypeOf(values.drivers[0].incidents[0].date).toEqualTypeOf<string>();
688
+ expectTypeOf(values.drivers[0].incidents[0].kind).toEqualTypeOf<string | undefined>();
689
+ expectTypeOf(values.drivers[0].incidents[0].claimAmountUsd).toEqualTypeOf<number | null>();
690
+
691
+ // object-in-list: nested spec inside an each spec.
692
+ expectTypeOf(values.vehicles[0].vin).toEqualTypeOf<string>();
693
+ expectTypeOf(values.vehicles[0].garagingAddress.line1).toEqualTypeOf<string>();
694
+ expectTypeOf(values.vehicles[0].garagingAddress.postalCode).toEqualTypeOf<string>();
695
+ expectTypeOf(values.vehicles[0].garagingAddress.city).toEqualTypeOf<string | undefined>();
696
+
697
+ // Nullable list: `| null` on the list survives, elements refine.
698
+ expectTypeOf(values.pastPolicies?.[0].insurer).toEqualTypeOf<string | undefined>();
699
+
700
+ // Disambiguation: `audit` refined as an object spec despite the
701
+ // `each` key.
702
+ expectTypeOf(values.audit.each).toEqualTypeOf<string>();
703
+ expectTypeOf(values.audit.reviewedBy).toEqualTypeOf<string | undefined>();
704
+
705
+ // Unconstrained structure passes through untouched.
706
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
707
+ expectTypeOf(values.discountCodes).toEqualTypeOf<string[]>();
708
+ expectTypeOf(values.deductibleUsd).toEqualTypeOf<number | undefined>();
709
+ },
710
+ });
711
+ });
712
+ });
713
+
714
+ describe('useFormState — hook-boundary regimes on the recursive grammar', () => {
715
+ it('without constraints, onSubmit receives the IDENTICAL form type at every depth', () => {
716
+ // With the default V = Validations<T>, the identity gate on Refine must
717
+ // short-circuit to T itself — not a union of structurally-identical
718
+ // mapped copies of each section (the one genuine bug the type spike
719
+ // found in the plan's sketch; toEqualTypeOf is the strict-identity pin).
720
+ useFormState({
721
+ initialValues: {} as InsuranceQuoteForm,
722
+ onSubmit: (values) => {
723
+ expectTypeOf(values).toEqualTypeOf<InsuranceQuoteForm>();
724
+ expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
725
+ expectTypeOf(values.drivers).toEqualTypeOf<InsuranceQuoteForm['drivers']>();
726
+ },
727
+ });
728
+ });
729
+
730
+ it('infers T from literal initialValues while contextually typing nested constraints', () => {
731
+ useFormState({
732
+ initialValues: {
733
+ name: undefined as string | undefined,
734
+ home: {
735
+ city: undefined as string | undefined,
736
+ zip: undefined as string | undefined,
737
+ },
738
+ pets: [] as Array<{ nickname: string | undefined }>,
739
+ },
740
+ constraints: {
741
+ name: notEmpty('name'),
742
+ home: { city: notEmpty('city') },
743
+ pets: { each: { nickname: notEmpty('nickname') } },
744
+ },
745
+ onSubmit: (values) => {
746
+ expectTypeOf(values.name).toEqualTypeOf<string>();
747
+ expectTypeOf(values.home.city).toEqualTypeOf<string>();
748
+ expectTypeOf(values.home.zip).toEqualTypeOf<string | undefined>();
749
+ expectTypeOf(values.pets[0].nickname).toEqualTypeOf<string>();
750
+ },
751
+ });
752
+ });
753
+
754
+ it('preserves refinement markers through a pre-built `as const satisfies` object at full depth', () => {
755
+ const prebuilt = {
756
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
757
+ homeAddress: {
758
+ city: notEmpty('city'),
759
+ },
760
+ drivers: {
761
+ each: {
762
+ name: notEmpty('name'),
763
+ incidents: {
764
+ each: { date: notEmpty('date') },
765
+ },
766
+ },
767
+ },
768
+ } as const satisfies Validations<InsuranceQuoteForm>;
769
+
770
+ // Direct Refine application, no hook in between.
771
+ type Submit = Refine<InsuranceQuoteForm, typeof prebuilt>;
772
+ expectTypeOf<Submit['email']>().toEqualTypeOf<string>();
773
+ expectTypeOf<Submit['homeAddress']['city']>().toEqualTypeOf<string>();
774
+ expectTypeOf<Submit['homeAddress']['line1']>().toEqualTypeOf<string | undefined>();
775
+ expectTypeOf<Submit['drivers'][number]['name']>().toEqualTypeOf<string>();
776
+ expectTypeOf<Submit['drivers'][number]['incidents'][number]['date']>().toEqualTypeOf<string>();
777
+ expectTypeOf<Submit['drivers'][number]['licenseState']>().toEqualTypeOf<string | undefined>();
778
+
779
+ useFormState({
780
+ initialValues: {} as InsuranceQuoteForm,
781
+ constraints: prebuilt,
782
+ onSubmit: (values) => {
783
+ expectTypeOf(values.email).toEqualTypeOf<string>();
784
+ expectTypeOf(values.drivers[0].incidents[0].date).toEqualTypeOf<string>();
785
+ },
786
+ });
787
+ });
788
+ });
789
+
790
+ declare const cond: boolean;
791
+
792
+ describe('useFormState — leaf forms on structural fields, and phase-1 soundness at depth', () => {
793
+ it('contextually types whole-section and whole-list leaf validators', () => {
794
+ useFormState({
795
+ initialValues: {} as InsuranceQuoteForm,
796
+ constraints: {
797
+ // A bare arrow on an OBJECT field is a whole-value validator, not a
798
+ // nested spec — the leaf forms stay legal for structural fields.
799
+ homeAddress: (val) => {
800
+ expectTypeOf(val).toEqualTypeOf<UsAddress>();
801
+ return null;
802
+ },
803
+ mailingAddress: (val) => {
804
+ expectTypeOf(val).toEqualTypeOf<UsAddress | undefined>();
805
+ return null;
806
+ },
807
+ // Whole-list validator array on a list field.
808
+ drivers: [
809
+ (val) => {
810
+ expectTypeOf(val).toEqualTypeOf<InsuranceQuoteForm['drivers']>();
811
+ return val.length > 0 ? null : 'at least one driver';
812
+ },
813
+ ],
814
+ },
815
+ onSubmit: (values) => {
816
+ // Whole-value leaf validators narrow nothing (no markers here).
817
+ expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
818
+ expectTypeOf(values.drivers).toEqualTypeOf<InsuranceQuoteForm['drivers']>();
819
+ },
820
+ });
821
+ });
822
+
823
+ it('keeps union-typed constraints per-branch sound inside nested and each specs', () => {
824
+ useFormState({
825
+ initialValues: {} as InsuranceQuoteForm,
826
+ constraints: {
827
+ homeAddress: {
828
+ // Only one branch runs: distribution must yield the union of the
829
+ // per-branch refinements, exactly as at the root (phase 1).
830
+ city: cond ? notEmpty('city') : minLength('city', 2),
831
+ // Array with a union-typed MEMBER: the member contributes only the
832
+ // intersection of its branches' excludes (never here — minLength
833
+ // narrows nothing), so the field must NOT refine.
834
+ state: [cond ? notEmpty('state') : minLength('state', 2)],
835
+ },
836
+ drivers: {
837
+ each: {
838
+ name: cond ? notEmpty('name') : minLength('name', 2),
839
+ },
840
+ },
841
+ },
842
+ onSubmit: (values) => {
843
+ // string (notEmpty branch) | string | undefined (minLength branch)
844
+ expectTypeOf(values.homeAddress.city).toEqualTypeOf<string | undefined>();
845
+ expectTypeOf(values.homeAddress.state).toEqualTypeOf<string | undefined>();
846
+ expectTypeOf(values.drivers[0].name).toEqualTypeOf<string | undefined>();
847
+ },
848
+ });
849
+ });
850
+ });
851
+
852
+ // Negative probes at depth. Anchor gotcha (from the spike): wrong-KEY
853
+ // rejections are TS2353 excess-property errors anchored at the offending
854
+ // leaf line, but wrong-VALIDATOR-INPUT rejections are TS2322 assignability
855
+ // errors anchored at the OUTERMOST constraint key — their `@ts-expect-error`
856
+ // directives must sit on the parent/root key's line.
857
+
858
+ describe('useFormState — recursive-grammar negative probes', () => {
859
+ it('rejects a wrong key inside a nested object spec', () => {
860
+ useFormState({
861
+ initialValues: {} as InsuranceQuoteForm,
862
+ constraints: {
863
+ homeAddress: {
864
+ // @ts-expect-error 'cityy' is not a field of UsAddress
865
+ cityy: notEmpty('cityy'),
866
+ },
867
+ },
868
+ });
869
+ });
870
+
871
+ it('rejects a wrong key inside an each spec', () => {
872
+ useFormState({
873
+ initialValues: {} as InsuranceQuoteForm,
874
+ constraints: {
875
+ drivers: {
876
+ each: {
877
+ // @ts-expect-error 'nam' is not a field of the driver element
878
+ nam: notEmpty('nam'),
879
+ },
880
+ },
881
+ },
882
+ });
883
+ });
884
+
885
+ it('rejects a wrong key inside a list-in-list each spec', () => {
886
+ useFormState({
887
+ initialValues: {} as InsuranceQuoteForm,
888
+ constraints: {
889
+ drivers: {
890
+ each: {
891
+ incidents: {
892
+ each: {
893
+ // @ts-expect-error 'datee' is not a field of the incident element
894
+ datee: notEmpty('datee'),
895
+ },
896
+ },
897
+ },
898
+ },
899
+ },
900
+ });
901
+ });
902
+
903
+ it('rejects a bare object spec (no `each`) on a list field', () => {
904
+ useFormState({
905
+ initialValues: {} as InsuranceQuoteForm,
906
+ constraints: {
907
+ drivers: {
908
+ // @ts-expect-error a list field takes { each: ... }, not a field
909
+ // map — EPC anchors at the unknown property
910
+ name: notEmpty('name'),
911
+ },
912
+ },
913
+ });
914
+ });
915
+
916
+ it('rejects an `each` spec on a plain object field', () => {
917
+ useFormState({
918
+ initialValues: {} as InsuranceQuoteForm,
919
+ constraints: {
920
+ homeAddress: {
921
+ // @ts-expect-error homeAddress is an object, not a list — no `each`
922
+ each: { city: notEmpty('city') },
923
+ },
924
+ },
925
+ });
926
+ });
927
+
928
+ it('rejects a nested spec on a scalar leaf', () => {
929
+ useFormState({
930
+ initialValues: {} as InsuranceQuoteForm,
931
+ constraints: {
932
+ email: {
933
+ // @ts-expect-error email is a scalar; only validators/arrays apply
934
+ domain: notEmpty('domain'),
935
+ },
936
+ },
937
+ });
938
+ });
939
+
940
+ it('rejects a wrong-input validator inside a nested spec', () => {
941
+ useFormState({
942
+ initialValues: {} as InsuranceQuoteForm,
943
+ constraints: {
944
+ // @ts-expect-error min validates numbers; postalCode is a string —
945
+ // the TS2322 anchors at the PARENT key, elaboration names postalCode
946
+ homeAddress: {
947
+ postalCode: min('postalCode', 0),
948
+ },
949
+ },
950
+ });
951
+ });
952
+
953
+ it('rejects a wrong-input validator in an array at list-in-list depth', () => {
954
+ useFormState({
955
+ initialValues: {} as InsuranceQuoteForm,
956
+ constraints: {
957
+ // @ts-expect-error min validates numbers; date is a string — the
958
+ // TS2322 anchors at the ROOT constraint key, elaboration walks
959
+ // each.incidents → each.date down to the parameter mismatch
960
+ drivers: {
961
+ each: {
962
+ incidents: {
963
+ each: {
964
+ date: [notEmpty('date'), min('date', 0)],
965
+ },
966
+ },
967
+ },
968
+ },
969
+ },
970
+ });
971
+ });
972
+
973
+ it('rejects a bare validator where an each spec map is required', () => {
974
+ useFormState({
975
+ initialValues: {} as InsuranceQuoteForm,
976
+ constraints: {
977
+ // @ts-expect-error `each` takes a per-field map, not a validator
978
+ // (rejected by the weak-type check; anchors at the parent key)
979
+ vehicles: {
980
+ each: minLength('vehicles', 1),
981
+ },
982
+ },
983
+ });
984
+ });
985
+ });
@@ -40,8 +40,8 @@ describe('useFormState', () => {
40
40
  constraints: { email: notEmpty('email') },
41
41
  }),
42
42
  );
43
- // The full structured shape: one entry per failing field, addressed by
44
- // a typed path (single-key on the flat grammar).
43
+ // The full structured shape: one entry per failing constrained node,
44
+ // addressed by a typed path (single-key for a root leaf).
45
45
  expect(result.current.errors).toEqual([
46
46
  { path: ['email'], error: "'email' cannot be empty" },
47
47
  ]);
@@ -1,4 +1,4 @@
1
- import type { FormValuesObject } from '../useFormState/types';
1
+ import type { FormValueList, FormValuesObject } from '../useFormState/types';
2
2
 
3
3
  // Phantom marker carried by validators. The runtime value of `__excludes`
4
4
  // is meaningless and never read — only its declared type matters. Required
@@ -23,16 +23,39 @@ export type Validator<Input, Excluded = never> =
23
23
  // structurally by `Refine<>`.
24
24
  export type FieldValidator<F> = (val: F) => string | null;
25
25
 
26
- // What a constraints key may map to — phase 1 of the target grammar (see
27
- // plan.md): the leaf forms only. A single validator, or an ordered list of
28
- // validators run first-error-wins (sugar over `allOf` at the constraint
29
- // site; `allOf` remains for building reusable composite validators). The
30
- // structural forms (nested `Validations`, list `each`) arrive in phases 2–3.
26
+ // What a constraints key may map to — the full target grammar (see plan.md).
27
+ // The leaf forms (a single validator, or an ordered list run
28
+ // first-error-wins sugar over `allOf` at the constraint site; `allOf`
29
+ // remains for building reusable composite validators) are legal for ANY
30
+ // field type, structural fields included: a bare arrow on an object field is
31
+ // a whole-section validator. The structural forms are directed by the
32
+ // FIELD's type, never guessed from the constraint's shape (see
33
+ // "Disambiguation" in plan.md): only an object-typed field admits a nested
34
+ // `Validations`, only a list-typed field admits `{ each: … }`. `F` is naked
35
+ // in the conditionals, so nullable sections/lists distribute — for
36
+ // `UsAddress | undefined` the object arm fires for the present member. Do
37
+ // NOT wrap `F` in `NonNullable` "to help": that breaks the distribution and
38
+ // blows the recursion stack (TS2589) — see the maintainer note in
39
+ // src/forms/CLAUDE.md.
31
40
  export type FieldConstraint<F> =
32
41
  | FieldValidator<F>
33
- | readonly FieldValidator<F>[];
42
+ | readonly FieldValidator<F>[]
43
+ | (F extends FormValuesObject ? Validations<F> : never)
44
+ | (F extends FormValueList ? ListConstraint<F[number]> : never);
34
45
 
35
- // The constraint for a per-field validation map.
46
+ // The constraint form for a list field: a spec applied to each element.
47
+ // NOTE: the type-level grammar admits `each` (proven with the rest of the
48
+ // recursive grammar by the phase-2 type spike), but the runtime walk for it
49
+ // is plan phase 3 — until it lands, an `each` constraint throws a clear
50
+ // error from the walk instead of silently not validating.
51
+ export type ListConstraint<Element extends FormValuesObject> = {
52
+ readonly each: Validations<Element>;
53
+ // room to grow, e.g. a `self` slot for list-level rules (min count,
54
+ // uniqueness) — not in scope for v1
55
+ };
56
+
57
+ // The constraint for a per-field validation map — recursive through nested
58
+ // object specs and list `each` specs.
36
59
  export type Validations<T extends FormValuesObject> = {
37
60
  readonly [K in keyof T]?: FieldConstraint<T[K]>;
38
61
  };
@@ -84,19 +107,56 @@ export type MemberExcludes<C extends readonly unknown[]> = {
84
107
  // validator *array* is the opposite regime: every member runs, so the union
85
108
  // ACROSS members is earned in a single `Exclude` — but each member's own
86
109
  // contribution is per-member sound (`MemberExcludes`): a union-typed member
87
- // claims only the intersection of its branches' excludes. Branch order
88
- // matters once the grammar grows structural forms (validator functions are
89
- // objects) `Refinement` stays first.
110
+ // claims only the intersection of its branches' excludes.
111
+ //
112
+ // Branch order: `Refinement` first (validator functions are objects — a
113
+ // marked validator must not fall into a structural arm), then arrays, then
114
+ // the structural arms. The structural arms interrogate the VALUE MODEL
115
+ // (`F`) before the constraint's shape, per the disambiguation doctrine: an
116
+ // object field that legitimately owns a key named `each` must be refined as
117
+ // a nested spec, so `F extends FormValueList` is asked before looking for
118
+ // `each`. (`F` also distributes here, which is what carries `| null` /
119
+ // `| undefined` on nullable sections/lists through around the refined
120
+ // interior.) A bare marker-less validator on a structural field lands in
121
+ // `RefineObject<F, C>` with `keyof C` empty, an identity map — refined type
122
+ // structurally unchanged, as a whole-value validator should be.
90
123
  type RefineField<F, C> = C extends Refinement<infer Excluded>
91
124
  ? Exclude<F, Excluded>
92
125
  : C extends readonly unknown[]
93
126
  ? Exclude<F, MemberExcludes<C>>
94
- : F;
127
+ : C extends object
128
+ ? F extends FormValueList
129
+ ? C extends { readonly each: infer E }
130
+ ? Array<RefineObject<F[number], E>>
131
+ : F
132
+ : F extends FormValuesObject
133
+ ? RefineObject<F, C>
134
+ : F
135
+ : F;
95
136
 
96
- // Applies each field's constraint to the form type: the submit-time type.
97
- // Singles narrow by their marker, arrays by the union of their members'
98
- // sound excludes; unconstrained fields and bare (marker-less) validators
99
- // pass through unchanged. Shallow by design — one mapped type, no recursion.
100
- export type Refine<T extends FormValuesObject, V extends Validations<T>> = {
137
+ type RefineObject<T, V> = {
101
138
  [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K];
102
139
  };
140
+
141
+ // Applies the constraints object to the form type, recursively: the
142
+ // submit-time type. Singles narrow by their marker, arrays by the union of
143
+ // their members' sound excludes, nested/`each` specs recurse; unconstrained
144
+ // fields and bare (marker-less) validators pass through unchanged.
145
+ //
146
+ // The identity gate up front is load-bearing, not an optimization (phase-2
147
+ // type spike, adjustment 2): with constraints omitted, the hook's default
148
+ // `V = Validations<T>` would send `FieldConstraint<F> | undefined` through
149
+ // the distributive walk at every key, and structural fields would come back
150
+ // as unions of structurally-identical mapped COPIES of each section —
151
+ // mutually assignable with `T` but not identity-equal (mangled hover types,
152
+ // fails strict type equality). `Validations<T> extends V` is true exactly
153
+ // when `V` is the default (or an empty/fully-widened literal — where no
154
+ // markers survive anyway), so short-circuit to `T` itself. Any concrete
155
+ // constraints literal is strictly narrower, so the real walk runs. Nested
156
+ // occurrences don't need the gate: for a concrete literal `V`, `keyof V`
157
+ // holds only the keys actually written, so uncovered fields take the `T[K]`
158
+ // arm verbatim at every level.
159
+ export type Refine<
160
+ T extends FormValuesObject,
161
+ V extends Validations<T>,
162
+ > = Validations<T> extends V ? T : RefineObject<T, V>;