Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/blue-geese-kneel.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions packages/form-core/src/FieldGroupApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import type { AnyFieldMetaBase, FieldOptions } from './FieldApi'
import type {
DeepKeys,
DeepKeysOfNonNullableType,
DeepKeysOfType,
DeepValue,
FieldsMap,
Expand Down Expand Up @@ -51,7 +52,7 @@ export interface FieldGroupOptions<
in out TFormData,
in out TFieldGroupData,
in out TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
in out TOnMount extends undefined | FormValidateOrFn<TFormData>,
in out TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down Expand Up @@ -114,7 +115,7 @@ export class FieldGroupApi<
in out TFormData,
in out TFieldGroupData,
in out TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
in out TOnMount extends undefined | FormValidateOrFn<TFormData>,
in out TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down
43 changes: 28 additions & 15 deletions packages/form-core/src/util-types.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How's type instantiation treating this type change? Have you tested them before / after? Did they explode or is it only a bit more?

I would be surprised if it did, but you never know with some of those seemingly simple changes.

If you haven't already, I can help out with testing it. Just let me know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured with the following command.
Instantiations increased slightly on this branch's HEAD c0d762dd compared to base 3886dcc5.

npx tsc --extendedDiagnostics

packages/form-core

metric base HEAD
Types 84,335 86,803
Instantiations 436,577 449,014

packages/react-form

metric base HEAD
Types 58,924 60,824
Instantiations 435,282 447,536

packages/preact-form

metric base HEAD
Types 51,525 52,567
Instantiations 322,435 326,877

packages/vue-form

metric base HEAD
Types 25,531 26,099
Instantiations 113,263 114,659

packages/solid-form

metric base HEAD
Types 37,855 39,301
Instantiations 203,009 210,621

Copy link
Copy Markdown
Contributor

@LeCarbonator LeCarbonator May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's only a minor amount because this would only account for a few unit tests.

I took the liberty to give it a try in my work space. We use TanStack Form a lot there and we have way more types, so the impact should be a bit clearer.

packages/react-form

metric v1.28 HEAD Diff (~)
Types 439'839 439'089 - 0.17%
Instantiations 2'983'698 2'978'088 - 0.18%
Memory 1'406'720K 1'412'563K + 0.41%

Looks clean!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking — appreciate it.
Let me know if there’s anything else I should work on.

Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,23 @@ export type DeepKeysAndValuesImpl<
T,
TParent extends AnyDeepKeyAndValue = never,
TAcc = never,
> = unknown extends T
? TAcc | UnknownDeepKeyAndValue<TParent>
: unknown extends T // this stops runaway recursion when T is any
? T
: T extends string | number | boolean | bigint | Date
? TAcc
: T extends ReadonlyArray<any>
? number extends T['length']
? DeepKeyAndValueArray<TParent, T, TAcc>
: DeepKeyAndValueTuple<TParent, T, TAcc>
: keyof T extends never
? TAcc | UnknownDeepKeyAndValue<TParent>
: T extends object
? DeepKeyAndValueObject<TParent, T, TAcc>
: TAcc
> = [T] extends [never]
? TAcc
: unknown extends T
? TAcc | UnknownDeepKeyAndValue<TParent>
: unknown extends T // this stops runaway recursion when T is any
? T
: T extends string | number | boolean | bigint | Date
? TAcc
: T extends ReadonlyArray<any>
? number extends T['length']
? DeepKeyAndValueArray<TParent, T, TAcc>
: DeepKeyAndValueTuple<TParent, T, TAcc>
: keyof T extends never
? TAcc | UnknownDeepKeyAndValue<TParent>
: T extends object
? DeepKeyAndValueObject<TParent, T, TAcc>
: TAcc
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This particular change addresses not just DeepKeysOfType, but also DeepKeys and DeepValue.

I see one test was adjusted to acknowledge this, but some additional LOC for DeepKeys and DeepValue would be fantastic. Not merely for this PR, but also for future typings to follow the spec.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for review!
I added case in this commit 2ee891c


export type DeepRecord<T> = {
[TRecord in DeepKeysAndValues<T> as TRecord['key']]: TRecord['value']
Expand Down Expand Up @@ -211,3 +213,14 @@ export type FieldsMap<TFormData, TFieldGroupData> =
TFieldGroupData[K]
>
}

type ExtractByNonNullableValue<T, TValue> = T extends { value: infer V }
? [NonNullable<V>] extends [never]
? never
Comment on lines +218 to +219
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the previous behavior inferred never when the value was nullish,
the changes were updated to continue inferring never for nullish value types.

: NonNullable<V> extends TValue
? T
: never
: never

export type DeepKeysOfNonNullableType<TData, TValue> =
ExtractByNonNullableValue<DeepKeysAndValues<TData>, TValue>['key']
Comment on lines +225 to +226
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After changing DeepKeysAndValues to include nullish values, a bug was introduced in the group API when using DeepKeysOfType.

When the value type is nullish, it previously evaluated as:

Extract<never, nullish | TValue>

However, after the change, it became:

Extract<nullish, nullish | TValue>

which now returns nullish.
To resolve this issue, I implemented a new utility type to correctly infer TFields in the group API.

39 changes: 39 additions & 0 deletions packages/form-core/tests/FieldGroupApi.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()

const wrongGroup = new FieldGroupApi({
form,
defaultValues: null,
// @ts-expect-error nullish-only fields cannot produce the group shape
fields: 'nope',
})
})
})
75 changes: 74 additions & 1 deletion packages/form-core/tests/util-types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,75 @@ expectTypeOf(
0 as never as DeepKeysOfType<NestedPartialSupport, number | undefined>,
).toEqualTypeOf<'meta.mainUser.age'>()

/**
* Properly handles properties whose values are purely nullish like so:
*/
type NullishValueSupport = {
nullValue: null
undefinedValue: undefined
nullishValue: null | undefined
}
expectTypeOf(0 as never as DeepKeys<NullishValueSupport>).toEqualTypeOf<
'nullValue' | 'undefinedValue' | 'nullishValue'
>()
expectTypeOf(
0 as never as DeepValue<NullishValueSupport, 'nullValue'>,
).toEqualTypeOf<null>()
expectTypeOf(
0 as never as DeepValue<NullishValueSupport, 'undefinedValue'>,
).toEqualTypeOf<undefined>()
expectTypeOf(
0 as never as DeepValue<NullishValueSupport, 'nullishValue'>,
).toEqualTypeOf<null | undefined>()

/**
* Properly handles tuple items whose values are purely nullish like so:
*/
type NullishTupleValueSupport = {
values: [null, undefined, null | undefined]
}
expectTypeOf(0 as never as DeepKeys<NullishTupleValueSupport>).toEqualTypeOf<
'values' | 'values[0]' | 'values[1]' | 'values[2]'
>()
expectTypeOf(
0 as never as DeepValue<NullishTupleValueSupport, 'values[0]'>,
).toEqualTypeOf<null>()
expectTypeOf(
0 as never as DeepValue<NullishTupleValueSupport, 'values[1]'>,
).toEqualTypeOf<undefined>()
expectTypeOf(
0 as never as DeepValue<NullishTupleValueSupport, 'values[2]'>,
).toEqualTypeOf<null | undefined>()

/**
* Properly handles array items whose values are purely nullish like so:
*/
type NullishArrayValueSupport = {
nullValues: null[]
undefinedValues: undefined[]
nullishValues: (null | undefined)[]
}
expectTypeOf(0 as never as DeepKeys<NullishArrayValueSupport>).toEqualTypeOf<
| 'nullValues'
| `nullValues[${number}]`
| 'undefinedValues'
| `undefinedValues[${number}]`
| 'nullishValues'
| `nullishValues[${number}]`
>()
expectTypeOf(
0 as never as DeepValue<NullishArrayValueSupport, `nullValues[${number}]`>,
).toEqualTypeOf<null>()
expectTypeOf(
0 as never as DeepValue<
NullishArrayValueSupport,
`undefinedValues[${number}]`
>,
).toEqualTypeOf<undefined>()
expectTypeOf(
0 as never as DeepValue<NullishArrayValueSupport, `nullishValues[${number}]`>,
).toEqualTypeOf<null | undefined>()

/**
* Properly handles `object` edgecase nesting like so:
*/
Expand Down Expand Up @@ -134,6 +203,7 @@ expectTypeOf(
*/
type DiscriminatedUnion = { name: string } & (
| { variant: 'foo' }
| { variant: null }
| { variant: 'bar'; baz: boolean }
)
expectTypeOf(0 as never as DeepKeys<DiscriminatedUnion>).toEqualTypeOf<
Expand All @@ -145,10 +215,13 @@ expectTypeOf(
expectTypeOf(
0 as never as DeepKeysOfType<DiscriminatedUnion, boolean>,
).toEqualTypeOf<'baz'>()
expectTypeOf(
0 as never as DeepKeysOfType<DiscriminatedUnion, null>,
).toEqualTypeOf<'variant'>()

type DiscriminatedUnionValueShared = DeepValue<DiscriminatedUnion, 'variant'>
expectTypeOf(0 as never as DiscriminatedUnionValueShared).toEqualTypeOf<
'foo' | 'bar'
'foo' | 'bar' | null
>()
type DiscriminatedUnionValueFixed = DeepValue<DiscriminatedUnion, 'baz'>
expectTypeOf(
Expand Down
4 changes: 2 additions & 2 deletions packages/preact-form/src/createFormHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
AnyFieldApi,
AnyFormApi,
BaseFormOptions,
DeepKeysOfType,
DeepKeysOfNonNullableType,
FieldApi,
FieldsMap,
FormAsyncValidateOrFn,
Expand Down Expand Up @@ -468,7 +468,7 @@ export function createFormHook<
>): <
TFormData,
TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down
6 changes: 3 additions & 3 deletions packages/preact-form/src/useFieldGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
import { useStore } from './useStore'
import type {
AnyFieldGroupApi,
DeepKeysOfType,
DeepKeysOfNonNullableType,
FieldGroupState,
FieldsMap,
FormAsyncValidateOrFn,
Expand Down Expand Up @@ -35,7 +35,7 @@ export type AppFieldExtendedReactFieldGroupApi<
TFormData,
TFieldGroupData,
TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down Expand Up @@ -91,7 +91,7 @@ export function useFieldGroup<
TFormData,
TFieldGroupData,
TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apply the same non-nullable field group fix to preact-form.

| FieldsMap<TFormData, TFieldGroupData>,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down
4 changes: 2 additions & 2 deletions packages/react-form/src/createFormHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
AnyFieldApi,
AnyFormApi,
BaseFormOptions,
DeepKeysOfType,
DeepKeysOfNonNullableType,
FieldApi,
FieldsMap,
FormAsyncValidateOrFn,
Expand Down Expand Up @@ -469,7 +469,7 @@ export function createFormHook<
>): <
TFormData,
TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down
6 changes: 3 additions & 3 deletions packages/react-form/src/useFieldGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core'
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
import type {
AnyFieldGroupApi,
DeepKeysOfType,
DeepKeysOfNonNullableType,
FieldGroupState,
FieldsMap,
FormAsyncValidateOrFn,
Expand Down Expand Up @@ -41,7 +41,7 @@ export type AppFieldExtendedReactFieldGroupApi<
TFormData,
TFieldGroupData,
TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down Expand Up @@ -97,7 +97,7 @@ export function useFieldGroup<
TFormData,
TFieldGroupData,
TFields extends
| DeepKeysOfType<TFormData, TFieldGroupData | null | undefined>
| DeepKeysOfNonNullableType<TFormData, TFieldGroupData>
| FieldsMap<TFormData, TFieldGroupData>,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
Expand Down
Loading
Loading