@structuralists/scaffolding 0.11.0 → 0.13.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,41 @@ 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. At
47
+ // runtime the walk validates EVERY element of a present list against the
48
+ // `each` spec, and each failure carries the numeric index step
49
+ // (`['drivers', 3, 'name']`) — the same step semantics as `read()`/`Path`,
50
+ // so `errorAt` and the bindings look element errors up like any other path.
51
+ // An absent (null/undefined) list has no elements to walk and skips — a
52
+ // "required list" is a leaf validator on the list field instead.
53
+ export type ListConstraint<Element extends FormValuesObject> = {
54
+ readonly each: Validations<Element>;
55
+ // room to grow, e.g. a `self` slot for list-level rules (min count,
56
+ // uniqueness) — not in scope for v1
57
+ };
58
+
59
+ // The constraint for a per-field validation map — recursive through nested
60
+ // object specs and list `each` specs.
36
61
  export type Validations<T extends FormValuesObject> = {
37
62
  readonly [K in keyof T]?: FieldConstraint<T[K]>;
38
63
  };
@@ -84,19 +109,56 @@ export type MemberExcludes<C extends readonly unknown[]> = {
84
109
  // validator *array* is the opposite regime: every member runs, so the union
85
110
  // ACROSS members is earned in a single `Exclude` — but each member's own
86
111
  // 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.
112
+ // claims only the intersection of its branches' excludes.
113
+ //
114
+ // Branch order: `Refinement` first (validator functions are objects — a
115
+ // marked validator must not fall into a structural arm), then arrays, then
116
+ // the structural arms. The structural arms interrogate the VALUE MODEL
117
+ // (`F`) before the constraint's shape, per the disambiguation doctrine: an
118
+ // object field that legitimately owns a key named `each` must be refined as
119
+ // a nested spec, so `F extends FormValueList` is asked before looking for
120
+ // `each`. (`F` also distributes here, which is what carries `| null` /
121
+ // `| undefined` on nullable sections/lists through around the refined
122
+ // interior.) A bare marker-less validator on a structural field lands in
123
+ // `RefineObject<F, C>` with `keyof C` empty, an identity map — refined type
124
+ // structurally unchanged, as a whole-value validator should be.
90
125
  type RefineField<F, C> = C extends Refinement<infer Excluded>
91
126
  ? Exclude<F, Excluded>
92
127
  : C extends readonly unknown[]
93
128
  ? Exclude<F, MemberExcludes<C>>
94
- : F;
129
+ : C extends object
130
+ ? F extends FormValueList
131
+ ? C extends { readonly each: infer E }
132
+ ? Array<RefineObject<F[number], E>>
133
+ : F
134
+ : F extends FormValuesObject
135
+ ? RefineObject<F, C>
136
+ : F
137
+ : F;
95
138
 
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>> = {
139
+ type RefineObject<T, V> = {
101
140
  [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K];
102
141
  };
142
+
143
+ // Applies the constraints object to the form type, recursively: the
144
+ // submit-time type. Singles narrow by their marker, arrays by the union of
145
+ // their members' sound excludes, nested/`each` specs recurse; unconstrained
146
+ // fields and bare (marker-less) validators pass through unchanged.
147
+ //
148
+ // The identity gate up front is load-bearing, not an optimization (phase-2
149
+ // type spike, adjustment 2): with constraints omitted, the hook's default
150
+ // `V = Validations<T>` would send `FieldConstraint<F> | undefined` through
151
+ // the distributive walk at every key, and structural fields would come back
152
+ // as unions of structurally-identical mapped COPIES of each section —
153
+ // mutually assignable with `T` but not identity-equal (mangled hover types,
154
+ // fails strict type equality). `Validations<T> extends V` is true exactly
155
+ // when `V` is the default (or an empty/fully-widened literal — where no
156
+ // markers survive anyway), so short-circuit to `T` itself. Any concrete
157
+ // constraints literal is strictly narrower, so the real walk runs. Nested
158
+ // occurrences don't need the gate: for a concrete literal `V`, `keyof V`
159
+ // holds only the keys actually written, so uncovered fields take the `T[K]`
160
+ // arm verbatim at every level.
161
+ export type Refine<
162
+ T extends FormValuesObject,
163
+ V extends Validations<T>,
164
+ > = Validations<T> extends V ? T : RefineObject<T, V>;