From bc15ad239ff84e5d1679a639edfaab26bdb40b6c Mon Sep 17 00:00:00 2001 From: mdm317 Date: Tue, 3 Mar 2026 14:25:33 +0900 Subject: [PATCH 1/4] fix: preserve nullable types in DeepValue --- packages/form-core/src/util-types.ts | 43 ++++++++++++------- packages/form-core/tests/util-types.test-d.ts | 6 ++- packages/react-form/src/createFormHook.tsx | 4 +- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index 8181dc920..897d3a059 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -135,21 +135,23 @@ export type DeepKeysAndValuesImpl< T, TParent extends AnyDeepKeyAndValue = never, TAcc = never, -> = unknown extends T - ? TAcc | UnknownDeepKeyAndValue - : unknown extends T // this stops runaway recursion when T is any - ? T - : T extends string | number | boolean | bigint | Date - ? TAcc - : T extends ReadonlyArray - ? number extends T['length'] - ? DeepKeyAndValueArray - : DeepKeyAndValueTuple - : keyof T extends never - ? TAcc | UnknownDeepKeyAndValue - : T extends object - ? DeepKeyAndValueObject - : TAcc +> = [T] extends [never] + ? TAcc + : unknown extends T + ? TAcc | UnknownDeepKeyAndValue + : unknown extends T // this stops runaway recursion when T is any + ? T + : T extends string | number | boolean | bigint | Date + ? TAcc + : T extends ReadonlyArray + ? number extends T['length'] + ? DeepKeyAndValueArray + : DeepKeyAndValueTuple + : keyof T extends never + ? TAcc | UnknownDeepKeyAndValue + : T extends object + ? DeepKeyAndValueObject + : TAcc export type DeepRecord = { [TRecord in DeepKeysAndValues as TRecord['key']]: TRecord['value'] @@ -194,3 +196,14 @@ export type FieldsMap = TFieldGroupData[K] > } + +type ExtractByNonNullableValue = T extends { value: infer V } + ? [NonNullable] extends [never] + ? never + : NonNullable extends TValue + ? T + : never + : never + +export type DeepKeysOfNonNullableType = + ExtractByNonNullableValue, TValue>['key'] diff --git a/packages/form-core/tests/util-types.test-d.ts b/packages/form-core/tests/util-types.test-d.ts index 2df6827e1..23e2d4588 100644 --- a/packages/form-core/tests/util-types.test-d.ts +++ b/packages/form-core/tests/util-types.test-d.ts @@ -134,6 +134,7 @@ expectTypeOf( */ type DiscriminatedUnion = { name: string } & ( | { variant: 'foo' } + | { variant: null } | { variant: 'bar'; baz: boolean } ) expectTypeOf(0 as never as DeepKeys).toEqualTypeOf< @@ -145,10 +146,13 @@ expectTypeOf( expectTypeOf( 0 as never as DeepKeysOfType, ).toEqualTypeOf<'baz'>() +expectTypeOf( + 0 as never as DeepKeysOfType, +).toEqualTypeOf<'variant'>() type DiscriminatedUnionValueShared = DeepValue expectTypeOf(0 as never as DiscriminatedUnionValueShared).toEqualTypeOf< - 'foo' | 'bar' + 'foo' | 'bar' | null >() type DiscriminatedUnionValueFixed = DeepValue expectTypeOf( diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 27cde30f9..4b0e7d34c 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -7,7 +7,7 @@ import type { AnyFieldApi, AnyFormApi, BaseFormOptions, - DeepKeysOfType, + DeepKeysOfNonNullableType, FieldApi, FieldsMap, FormAsyncValidateOrFn, @@ -468,7 +468,7 @@ export function createFormHook< >): < TFormData, TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, From 9e4fc5201711ae9f7849b5b136e7dc459f80352a Mon Sep 17 00:00:00 2001 From: mdm317 Date: Tue, 3 Mar 2026 15:04:20 +0900 Subject: [PATCH 2/4] test: verify field groups reject nullish-only paths --- packages/form-core/src/FieldGroupApi.ts | 5 +- .../form-core/tests/FieldGroupApi.test-d.ts | 39 ++++++++++++++++ packages/react-form/src/useFieldGroup.tsx | 6 +-- .../tests/createFormHook.test-d.tsx | 46 ++++++++++++++++++- packages/solid-form/src/createFieldGroup.tsx | 6 +-- packages/solid-form/src/createFormHook.tsx | 4 +- .../tests/createFormHook.test-d.tsx | 37 ++++++++++++++- 7 files changed, 131 insertions(+), 12 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index e69a29fc9..f41303f21 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -9,6 +9,7 @@ import type { import type { AnyFieldMetaBase, FieldOptions } from './FieldApi' import type { DeepKeys, + DeepKeysOfNonNullableType, DeepKeysOfType, DeepValue, FieldsMap, @@ -50,7 +51,7 @@ export interface FieldGroupOptions< in out TFormData, in out TFieldGroupData, in out TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, @@ -113,7 +114,7 @@ export class FieldGroupApi< in out TFormData, in out TFieldGroupData, in out TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, diff --git a/packages/form-core/tests/FieldGroupApi.test-d.ts b/packages/form-core/tests/FieldGroupApi.test-d.ts index 1e0ed9e49..15398dde4 100644 --- a/packages/form-core/tests/FieldGroupApi.test-d.ts +++ b/packages/form-core/tests/FieldGroupApi.test-d.ts @@ -201,4 +201,43 @@ describe('fieldGroupApi', () => { }, }) }) + + it('should reject nullish-only field-group paths', () => { + type FormValues = { + foo: + | { + bar: string + } + | null + | undefined + nope: null | undefined + } + + const defaultValues: FormValues = { + foo: { bar: '' }, + nope: null, + } + + const form = new FormApi({ + defaultValues, + }) + + const group = new FieldGroupApi({ + form, + defaultValues: { bar: '' }, + fields: 'foo', + }) + + expectTypeOf(group.state.values).toEqualTypeOf<{ + bar: string + }>() + expectTypeOf(group.state.values.bar).toEqualTypeOf() + + const wrongGroup = new FieldGroupApi({ + form, + defaultValues: null, + // @ts-expect-error nullish-only fields cannot produce the group shape + fields: 'nope', + }) + }) }) diff --git a/packages/react-form/src/useFieldGroup.tsx b/packages/react-form/src/useFieldGroup.tsx index 063187c52..1d808aa17 100644 --- a/packages/react-form/src/useFieldGroup.tsx +++ b/packages/react-form/src/useFieldGroup.tsx @@ -6,7 +6,7 @@ import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { AnyFieldGroupApi, - DeepKeysOfType, + DeepKeysOfNonNullableType, FieldGroupState, FieldsMap, FormAsyncValidateOrFn, @@ -41,7 +41,7 @@ export type AppFieldExtendedReactFieldGroupApi< TFormData, TFieldGroupData, TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -97,7 +97,7 @@ export function useFieldGroup< TFormData, TFieldGroupData, TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx index b8a9b3971..eaa82bd82 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest' import { formOptions } from '@tanstack/form-core' -import { createFormHook, createFormHookContexts } from '../src' +import { createFormHook, createFormHookContexts, useFieldGroup } from '../src' const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts() @@ -820,6 +820,50 @@ describe('createFormHook', () => { const Component5 = }) + it('useFieldGroup should reject nullish-only field-group paths', () => { + const groupFields = { + name: '', + } + + type WrapperValues = { + namespace3: { name: string } | null | undefined + nope: null | undefined + } + + const defaultValues: WrapperValues = { + namespace3: null, + nope: null, + } + + const form = useAppForm({ + defaultValues, + }) + + const group = useFieldGroup({ + form, + defaultValues: groupFields, + fields: 'namespace3', + formComponents: { + Test, + }, + }) + + expectTypeOf(group.state.values).toEqualTypeOf<{ + name: string + }>() + expectTypeOf(group.state.values.name).toEqualTypeOf() + + const wrongGroup = useFieldGroup({ + form, + defaultValues: groupFields, + // @ts-expect-error nullish-only fields cannot produce the group shape + fields: 'nope', + formComponents: { + Test, + }, + }) + }) + 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/createFieldGroup.tsx b/packages/solid-form/src/createFieldGroup.tsx index 0310dd9d9..d157adc04 100644 --- a/packages/solid-form/src/createFieldGroup.tsx +++ b/packages/solid-form/src/createFieldGroup.tsx @@ -3,7 +3,7 @@ import { useStore } from '@tanstack/solid-store' import { onCleanup, onMount } from 'solid-js' import type { Component, JSX, ParentProps } from 'solid-js' import type { - DeepKeysOfType, + DeepKeysOfNonNullableType, FieldGroupState, FieldsMap, FormAsyncValidateOrFn, @@ -19,7 +19,7 @@ export type AppFieldExtendedSolidFieldGroupApi< TFormData, TFieldGroupData, TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -75,7 +75,7 @@ export function createFieldGroup< TFormData, TFieldGroupData, TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index c94b365c8..75262e059 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -10,7 +10,7 @@ import type { AnyFieldApi, AnyFormApi, BaseFormOptions, - DeepKeysOfType, + DeepKeysOfNonNullableType, FieldApi, FieldsMap, FormAsyncValidateOrFn, @@ -488,7 +488,7 @@ export function createFormHook< >): < TFormData, TFields extends - | DeepKeysOfType + | DeepKeysOfNonNullableType | FieldsMap, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, diff --git a/packages/solid-form/tests/createFormHook.test-d.tsx b/packages/solid-form/tests/createFormHook.test-d.tsx index 067c2be1b..a2bffc6c8 100644 --- a/packages/solid-form/tests/createFormHook.test-d.tsx +++ b/packages/solid-form/tests/createFormHook.test-d.tsx @@ -10,7 +10,7 @@ function Test() { return null } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { Test, }, @@ -249,4 +249,39 @@ describe('createFormHook', () => { ) }) + + it('withFieldGroup should reject nullish-only field-group paths', () => { + const groupFields = { + name: '', + } + + type WrapperValues = { + namespace3: { name: string } | null | undefined + nope: null | undefined + } + + const defaultValues: WrapperValues = { + namespace3: null, + nope: null, + } + + const FieldGroup = withFieldGroup({ + defaultValues: groupFields, + render: function Render({ group }) { + expectTypeOf(group.state.values).toEqualTypeOf<{ + name: string + }>() + expectTypeOf(group.state.values.name).toEqualTypeOf() + return null + }, + }) + + const form = useAppForm(() => ({ + defaultValues, + })) + + const Component = + // @ts-expect-error nullish-only fields cannot produce the group shape + const WrongComponent = + }) }) From e57ffb38fa5e3350409d9b501c305b45488c4361 Mon Sep 17 00:00:00 2001 From: mdm317 Date: Tue, 3 Mar 2026 15:15:55 +0900 Subject: [PATCH 3/4] test : add testcase from issue --- .../tests/createFormHook.test-d.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx index eaa82bd82..febb8bc00 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -270,6 +270,32 @@ describe('createFormHook', () => { ) }) + it('should preserve undefined for union-only field values', () => { + type UnionType = + | { + id: string + } + | { + id: undefined + } + + const WithUnionForm = withForm({ + defaultValues: {} as UnionType, + render: ({ form }) => { + return ( + + {(field) => { + expectTypeOf(field.state.value).toEqualTypeOf() + // @ts-expect-error id can be undefined + const unsafeLength = field.state.value.length + return unsafeLength + }} + + ) + }, + }) + }) + it('should infer subset values and props when calling withFieldGroup', () => { type Person = { firstName: string From 81806f87eb0be16c246e1ba0e1301d1851306f4f Mon Sep 17 00:00:00 2001 From: mdm317 Date: Tue, 3 Mar 2026 16:05:59 +0900 Subject: [PATCH 4/4] changeset --- .changeset/blue-geese-kneel.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/blue-geese-kneel.md diff --git a/.changeset/blue-geese-kneel.md b/.changeset/blue-geese-kneel.md new file mode 100644 index 000000000..16121eed9 --- /dev/null +++ b/.changeset/blue-geese-kneel.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-form': patch +'@tanstack/solid-form': patch +'@tanstack/form-core': patch +--- + +Fix `DeepKeysAndValues` to correctly handle union types whose values include nullish types and preserve them in the resulting value type