@structuralists/scaffolding 0.10.2 → 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.
Files changed (35) hide show
  1. package/eslint.config.mjs +3 -3
  2. package/package.json +1 -1
  3. package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
  4. package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
  5. package/src/components/Json/JsonTable/index.tsx +13 -6
  6. package/src/components/Json/JsonTable/styles.module.css +20 -0
  7. package/src/components/Json/JsonTable/types.ts +3 -5
  8. package/src/forms/CLAUDE.md +195 -41
  9. package/src/forms/elements/Input/index.tsx +2 -0
  10. package/src/forms/elements/Input/types.ts +2 -1
  11. package/src/forms/plan.md +146 -29
  12. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  13. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  14. package/src/forms/state/path/path.test.ts +71 -1
  15. package/src/forms/state/path/path.ts +50 -0
  16. package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
  17. package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
  18. package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
  19. package/src/forms/state/useFormState/deriveErrors.ts +10 -10
  20. package/src/forms/state/useFormState/errorAt.test.ts +3 -3
  21. package/src/forms/state/useFormState/errorAt.ts +8 -12
  22. package/src/forms/state/useFormState/inspectable.test.ts +9 -9
  23. package/src/forms/state/useFormState/inspectable.ts +5 -7
  24. package/src/forms/state/useFormState/types.ts +35 -4
  25. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  26. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  27. package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
  28. package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
  29. package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
  30. package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
  31. package/src/forms/state/useFormState/useFormState.ts +12 -3
  32. package/src/forms/state/validations/types.ts +77 -17
  33. package/src/forms/state/validations/walk.test.ts +159 -19
  34. package/src/forms/state/validations/walk.ts +86 -25
  35. package/tokens.css +55 -0
@@ -2,9 +2,11 @@ import { describe, it, expectTypeOf } from 'vitest';
2
2
  import { useFormState } from './useFormState';
3
3
  import { errorAt } from './errorAt';
4
4
  import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
5
- import type { FormErrors } from './types';
5
+ import type { FormErrors, FormFieldProps } from './types';
6
6
  import type { Refine, Validations } from '../validations/types';
7
7
  import type { Path, ValueAt } from '../path/types';
8
+ import type { TextInputForFormProps } from '../bindings/TextInputForForm';
9
+ import type { SingleSelectForFormProps } from '../bindings/SingleSelectForForm';
8
10
 
9
11
  // These tests exercise the headline feature end-to-end at the hook boundary:
10
12
  // the type `onSubmit` receives must be the *refined* form type. The two
@@ -308,6 +310,12 @@ type InsuranceQuoteForm = {
308
310
  referralSource: string | null;
309
311
  notes: string | undefined;
310
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
+ };
311
319
  paperlessBilling: boolean;
312
320
  };
313
321
 
@@ -425,6 +433,83 @@ describe('useFormState narrowing at realistic scale', () => {
425
433
  errorAt(errors, ['email', 'domain']);
426
434
  });
427
435
 
436
+ it('getFormFieldPropsAt infers FormFieldProps<ValueAt> at deep paths, inline at the call site', () => {
437
+ const form = useFormState({
438
+ initialValues: {} as InsuranceQuoteForm,
439
+ constraints: { email: notEmpty('email') },
440
+ });
441
+
442
+ // Flat and deep paths: value/onChange typed by ValueAt<T, P>.
443
+ expectTypeOf(form.getFormFieldPropsAt(['email'])).toEqualTypeOf<
444
+ FormFieldProps<string | undefined>
445
+ >();
446
+ expectTypeOf(
447
+ form.getFormFieldPropsAt(['homeAddress', 'city']),
448
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
449
+ expectTypeOf(
450
+ form.getFormFieldPropsAt(['drivers', 0, 'incidents', 1, 'claimAmountUsd']),
451
+ ).toEqualTypeOf<FormFieldProps<number | null>>();
452
+ expectTypeOf(
453
+ form.getFormFieldPropsAt(['vehicles', 0, 'garagingAddress', 'postalCode']),
454
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
455
+
456
+ // Nullable-object semantics (PR #17) flow through the binding: stepping
457
+ // through a dead ancestor adds `| undefined`, stopping AT a nullable
458
+ // field keeps its exact type.
459
+ expectTypeOf(
460
+ form.getFormFieldPropsAt(['coApplicant', 'sharesResidence']),
461
+ ).toEqualTypeOf<FormFieldProps<boolean | undefined>>();
462
+ expectTypeOf(
463
+ form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
464
+ ).toEqualTypeOf<FormFieldProps<string | null | undefined>>();
465
+ expectTypeOf(form.getFormFieldPropsAt(['employer'])).toEqualTypeOf<
466
+ FormFieldProps<string | null>
467
+ >();
468
+
469
+ // Only Path<T> is admitted.
470
+ // @ts-expect-error 'emial' is not a field of the form
471
+ form.getFormFieldPropsAt(['emial']);
472
+ // @ts-expect-error no paths exist below a scalar leaf
473
+ form.getFormFieldPropsAt(['email', 'domain']);
474
+ });
475
+
476
+ it('element wrappers accept only shape-compatible bindings', () => {
477
+ const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
478
+
479
+ type TextBinding = TextInputForFormProps['formFieldProps'];
480
+
481
+ // Text-shaped fields bind: V must sit between the wrapper's emit type
482
+ // (string) and display type (string | null | undefined).
483
+ expectTypeOf(
484
+ form.getFormFieldPropsAt(['homeAddress', 'city']),
485
+ ).toMatchTypeOf<TextBinding>();
486
+ expectTypeOf(form.getFormFieldPropsAt(['employer'])).toMatchTypeOf<TextBinding>();
487
+ expectTypeOf(
488
+ form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
489
+ ).toMatchTypeOf<TextBinding>();
490
+
491
+ // Wrong-shaped bindings fail at the formFieldProps prop.
492
+ // @ts-expect-error a number-typed path cannot bind to a text element
493
+ const numberIntoText: TextBinding = form.getFormFieldPropsAt(['deductibleUsd']);
494
+ // @ts-expect-error a boolean-typed path cannot bind to a text element
495
+ const booleanIntoText: TextBinding = form.getFormFieldPropsAt(['agreedToTerms']);
496
+
497
+ // The select wrapper lines its options' literal union up with the field:
498
+ // emitting a wider type than the field holds is rejected.
499
+ type CoverageBinding = SingleSelectForFormProps<
500
+ 'liability' | 'comprehensive'
501
+ >['formFieldProps'];
502
+ expectTypeOf<
503
+ FormFieldProps<'liability' | 'comprehensive' | undefined>
504
+ >().toMatchTypeOf<CoverageBinding>();
505
+ // @ts-expect-error a plain-string field would accept values outside the options
506
+ const stringIntoSelect: CoverageBinding = form.getFormFieldPropsAt(['coverageType']);
507
+
508
+ void numberIntoText;
509
+ void booleanIntoText;
510
+ void stringIntoSelect;
511
+ });
512
+
428
513
  it('paths through optional sections and nullable lists resolve, at scale', () => {
429
514
  // The latent hole this pins: Path admitted these paths all along, but
430
515
  // ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
@@ -468,3 +553,433 @@ describe('useFormState narrowing at realistic scale', () => {
468
553
  >().toEqualTypeOf<string | null | undefined>();
469
554
  });
470
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
  ]);
@@ -5,6 +5,7 @@ import type {
5
5
  FormValuesObject,
6
6
  UnionPolicyCheck,
7
7
  } from './types';
8
+ import { useFieldBinding } from './useFieldBinding';
8
9
  import { useFormDebugger } from './useFormDebugger';
9
10
  import { useFormSubmit } from './useFormSubmit';
10
11
  import type { Refine, Validations } from '../validations/types';
@@ -30,8 +31,8 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
30
31
  // Plumbing only: each slice of form state lives in its own pure function or
31
32
  // focused hook, and this hook just links them up and recomposes their
32
33
  // outputs into the `FormHelpers<T>` surface. Values state is the one slice
33
- // kept inline — today it is a bare `useState`, and the granular setters that
34
- // would earn it a module of its own arrive with the path-based grammar.
34
+ // kept inline — a bare `useState`; the granular path writes layer on top of
35
+ // its setter (`useFieldBinding` funnels `write()` results through it).
35
36
  export const useFormState = <
36
37
  T extends FormValuesObject,
37
38
  const V extends Validations<T> = Validations<T>,
@@ -53,8 +54,15 @@ export const useFormState = <
53
54
  onSubmit,
54
55
  });
55
56
 
57
+ const { touched, getFormFieldPropsAt } = useFieldBinding({
58
+ values,
59
+ onValueChanges,
60
+ errors,
61
+ submitAttempted,
62
+ });
63
+
56
64
  const Debugger = useFormDebugger({
57
- snapshot: { values, errors, isValid, submitAttempted },
65
+ snapshot: { values, errors, isValid, submitAttempted, touched },
58
66
  });
59
67
 
60
68
  return {
@@ -64,6 +72,7 @@ export const useFormState = <
64
72
  isValid,
65
73
  submitAttempted,
66
74
  submit,
75
+ getFormFieldPropsAt,
67
76
  Debugger,
68
77
  };
69
78
  };