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 @@ -9,6 +9,7 @@ import type {
import type { AnyFieldMetaBase, FieldOptions } from './FieldApi'
import type {
DeepKeys,
DeepKeysOfNonNullableType,
DeepKeysOfType,
DeepValue,
FieldsMap,
Expand Down Expand Up @@ -50,7 +51,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 @@ -113,7 +114,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
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,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

export type DeepRecord<T> = {
[TRecord in DeepKeysAndValues<T> as TRecord['key']]: TRecord['value']
Expand Down Expand Up @@ -194,3 +196,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 +201 to +202
Copy link
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 +208 to +209
Copy link
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',
})
})
})
6 changes: 5 additions & 1 deletion packages/form-core/tests/util-types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,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 +146,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/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 @@ -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/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
72 changes: 71 additions & 1 deletion packages/react-form/tests/createFormHook.test-d.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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 (
<form.AppField name="id">
{(field) => {
expectTypeOf(field.state.value).toEqualTypeOf<string | undefined>()
// @ts-expect-error id can be undefined
const unsafeLength = field.state.value.length
return unsafeLength
}}
</form.AppField>
)
},
})
})

it('should infer subset values and props when calling withFieldGroup', () => {
type Person = {
firstName: string
Expand Down Expand Up @@ -820,6 +846,50 @@ describe('createFormHook', () => {
const Component5 = <FieldGroup form={form} fields="nope2" />
})

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<string>()

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
Expand Down
6 changes: 3 additions & 3 deletions packages/solid-form/src/createFieldGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +19,7 @@ export type AppFieldExtendedSolidFieldGroupApi<
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 @@ -75,7 +75,7 @@ export function createFieldGroup<
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
4 changes: 2 additions & 2 deletions packages/solid-form/src/createFormHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
AnyFieldApi,
AnyFormApi,
BaseFormOptions,
DeepKeysOfType,
DeepKeysOfNonNullableType,
FieldApi,
FieldsMap,
FormAsyncValidateOrFn,
Expand Down Expand Up @@ -488,7 +488,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
37 changes: 36 additions & 1 deletion packages/solid-form/tests/createFormHook.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function Test() {
return null
}

const { useAppForm, withForm } = createFormHook({
const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
Test,
},
Expand Down Expand Up @@ -249,4 +249,39 @@ describe('createFormHook', () => {
<WithFormComponent form={incorrectAppForm} prop1="test" prop2={10} />
)
})

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<string>()
return null
},
})

const form = useAppForm(() => ({
defaultValues,
}))

const Component = <FieldGroup form={form} fields="namespace3" />
// @ts-expect-error nullish-only fields cannot produce the group shape
const WrongComponent = <FieldGroup form={form} fields="nope" />
})
})