From 9bdf5e388ac4c3fc994173ea357671253a507a84 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Thu, 23 Jul 2026 11:13:25 +0800 Subject: [PATCH] fix(form): require compatible withFieldGroup field types --- packages/form-core/src/util-types.ts | 70 +++++++++++++++++++ packages/preact-form/src/createFormHook.tsx | 7 +- .../tests/createFormHook.test-d.tsx | 46 ++++++++++++ packages/react-form/src/createFormHook.tsx | 7 +- .../tests/createFormHook.test-d.tsx | 46 ++++++++++++ packages/solid-form/src/createFormHook.tsx | 7 +- 6 files changed, 174 insertions(+), 9 deletions(-) diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index a27da3a92..eb0fced7f 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -196,6 +196,60 @@ export type DeepKeysOfType = Extract< AnyDeepKeyAndValue >['key'] +type IsEqual = [TValue] extends [TOther] + ? [TOther] extends [TValue] + ? true + : false + : false + +/** + * Checks whether the form value can safely back a field group value. + * Extra form fields are allowed, but fields exposed by the field group must match exactly. + */ +type IsFieldGroupCompatible = [ + TFieldGroupValue, +] extends [ReadonlyArray] + ? [TFormValue] extends [ReadonlyArray] + ? IsFieldGroupCompatible + : false + : [TFieldGroupValue] extends [object] + ? [TFormValue] extends [object] + ? false extends { + [K in keyof TFieldGroupValue]-?: K extends keyof TFormValue + ? IsFieldGroupCompatible + : false + }[keyof TFieldGroupValue] + ? false + : true + : false + : IsEqual + +/** + * The keys of an object or array, deeply nested and compatible with a field group value. + */ +export type DeepKeysOfFieldGroupType = unknown extends TData + ? string + : DeepKeysAndValues extends infer TDeepKeyAndValue + ? TDeepKeyAndValue extends AnyDeepKeyAndValue + ? IsFieldGroupCompatible, TValue> extends true + ? TKey + : never + : never + : never + +/** + * The keys of an object or array, deeply nested and compatible with a mapped field group value. + */ +export type DeepKeysOfFieldGroupFieldType = unknown extends TData + ? string + : DeepKeysAndValues extends infer TDeepKeyAndValue + ? TDeepKeyAndValue extends AnyDeepKeyAndValue + ? IsFieldGroupCompatible extends true + ? TKey + : never + : never + : never + /** * Maps the deep keys of TFormData to the shallow keys of TFieldGroupData. * Since using template strings as keys is impractical, it relies on shallow keys only. @@ -211,3 +265,19 @@ export type FieldsMap = TFieldGroupData[K] > } + +/** + * Maps the deep keys of TFormData to the shallow keys of TFieldGroupData with compatible field group value types. + * Since using template strings as keys is impractical, it relies on shallow keys only. + */ +export type FieldGroupFieldsMap = + TFieldGroupData extends any[] + ? never + : string extends keyof TFieldGroupData + ? never + : { + [K in keyof TFieldGroupData]: DeepKeysOfFieldGroupFieldType< + TFormData, + TFieldGroupData[K] + > + } diff --git a/packages/preact-form/src/createFormHook.tsx b/packages/preact-form/src/createFormHook.tsx index 5f759fa52..a8bbd6416 100644 --- a/packages/preact-form/src/createFormHook.tsx +++ b/packages/preact-form/src/createFormHook.tsx @@ -7,8 +7,9 @@ import type { AnyFieldApi, AnyFormApi, BaseFormOptions, - DeepKeysOfType, + DeepKeysOfFieldGroupType, FieldApi, + FieldGroupFieldsMap, FieldsMap, FormAsyncValidateOrFn, FormOptions, @@ -468,8 +469,8 @@ export function createFormHook< >): < TFormData, TFields extends - | DeepKeysOfType - | FieldsMap, + | DeepKeysOfFieldGroupType + | FieldGroupFieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn, diff --git a/packages/preact-form/tests/createFormHook.test-d.tsx b/packages/preact-form/tests/createFormHook.test-d.tsx index 4f8da4740..03bfdacfd 100644 --- a/packages/preact-form/tests/createFormHook.test-d.tsx +++ b/packages/preact-form/tests/createFormHook.test-d.tsx @@ -821,6 +821,52 @@ describe('createFormHook', () => { const Component5 = }) + it('should require exact field value types for withFieldGroup fields', () => { + type PasswordFields = { + password: string | null + } + + const FieldGroupPasswordFields = withFieldGroup({ + defaultValues: { password: '' } as PasswordFields, + render: function Render() { + return <> + }, + }) + + const form = useAppForm({ + defaultValues: { + reusable: { password: '' }, + exact: { password: null as string | null }, + }, + }) + + const ExactComponent = ( + + ) + const ExactMappedComponent = ( + + ) + const TooWideComponent = ( + + ) + const TooWideMappedComponent = ( + + ) + }) + it('should allow interfaces without index signatures to be assigned to `props` in withForm and withFormGroup', () => { interface TestNoSignature { title: string diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 4008c47fc..6e3721a7c 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -7,8 +7,9 @@ import type { AnyFieldApi, AnyFormApi, BaseFormOptions, - DeepKeysOfType, + DeepKeysOfFieldGroupType, FieldApi, + FieldGroupFieldsMap, FieldsMap, FormAsyncValidateOrFn, FormOptions, @@ -469,8 +470,8 @@ export function createFormHook< >): < TFormData, TFields extends - | DeepKeysOfType - | FieldsMap, + | DeepKeysOfFieldGroupType + | FieldGroupFieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn, diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx index c44e9fa8c..8c1c8eaef 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -820,6 +820,52 @@ describe('createFormHook', () => { const Component5 = }) + it('should require exact field value types for withFieldGroup fields', () => { + type PasswordFields = { + password: string | null + } + + const FieldGroupPasswordFields = withFieldGroup({ + defaultValues: { password: '' } as PasswordFields, + render: function Render() { + return <> + }, + }) + + const form = useAppForm({ + defaultValues: { + reusable: { password: '' }, + exact: { password: null as string | null }, + }, + }) + + const ExactComponent = ( + + ) + const ExactMappedComponent = ( + + ) + const TooWideComponent = ( + + ) + const TooWideMappedComponent = ( + + ) + }) + it('should allow interfaces without index signatures to be assigned to `props` in withForm and withFormGroup', () => { interface TestNoSignature { title: string diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index a86afab24..f7c8e7948 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -11,8 +11,9 @@ import type { AnyFieldApi, AnyFormApi, BaseFormOptions, - DeepKeysOfType, + DeepKeysOfFieldGroupType, FieldApi, + FieldGroupFieldsMap, FieldsMap, FormAsyncValidateOrFn, FormOptions, @@ -493,8 +494,8 @@ export function createFormHook< >): < TFormData, TFields extends - | DeepKeysOfType - | FieldsMap, + | DeepKeysOfFieldGroupType + | FieldGroupFieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn,