Skip to content

fix(form-core): Only allow array deep keys in array methods #1495

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 9, 2025
Merged
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
18 changes: 10 additions & 8 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type {
ValidationErrorMap,
ValidationErrorMapKeys,
} from './types'
import type { DeepKeys, DeepValue } from './util-types'
import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types'
import type { Updater } from './utils'

/**
Expand Down Expand Up @@ -1309,7 +1309,9 @@ export class FormApi<
/**
* Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type.
*/
validateArrayFieldsStartingFrom = async <TField extends DeepKeys<TFormData>>(
validateArrayFieldsStartingFrom = async <
TField extends DeepKeysOfType<TFormData, any[]>,
>(
field: TField,
index: number,
cause: ValidationCause,
Expand Down Expand Up @@ -1959,7 +1961,7 @@ export class FormApi<
/**
* Pushes a value into an array field.
*/
pushFieldValue = <TField extends DeepKeys<TFormData>>(
pushFieldValue = <TField extends DeepKeysOfType<TFormData, any[]>>(
field: TField,
value: DeepValue<TFormData, TField> extends any[]
? DeepValue<TFormData, TField>[number]
Expand All @@ -1974,7 +1976,7 @@ export class FormApi<
this.validateField(field, 'change')
}

insertFieldValue = async <TField extends DeepKeys<TFormData>>(
insertFieldValue = async <TField extends DeepKeysOfType<TFormData, any[]>>(
field: TField,
index: number,
value: DeepValue<TFormData, TField> extends any[]
Expand Down Expand Up @@ -2006,7 +2008,7 @@ export class FormApi<
/**
* Replaces a value into an array field at the specified index.
*/
replaceFieldValue = async <TField extends DeepKeys<TFormData>>(
replaceFieldValue = async <TField extends DeepKeysOfType<TFormData, any[]>>(
field: TField,
index: number,
value: DeepValue<TFormData, TField> extends any[]
Expand All @@ -2032,7 +2034,7 @@ export class FormApi<
/**
* Removes a value from an array field at the specified index.
*/
removeFieldValue = async <TField extends DeepKeys<TFormData>>(
removeFieldValue = async <TField extends DeepKeysOfType<TFormData, any[]>>(
field: TField,
index: number,
opts?: UpdateMetaOptions,
Expand Down Expand Up @@ -2069,7 +2071,7 @@ export class FormApi<
/**
* Swaps the values at the specified indices within an array field.
*/
swapFieldValues = <TField extends DeepKeys<TFormData>>(
swapFieldValues = <TField extends DeepKeysOfType<TFormData, any[]>>(
field: TField,
index1: number,
index2: number,
Expand Down Expand Up @@ -2098,7 +2100,7 @@ export class FormApi<
/**
* Moves the value at the first specified index to the second specified index within an array field.
*/
moveFieldValues = <TField extends DeepKeys<TFormData>>(
moveFieldValues = <TField extends DeepKeysOfType<TFormData, any[]>>(
field: TField,
index1: number,
index2: number,
Expand Down
17 changes: 14 additions & 3 deletions packages/form-core/src/util-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ type Try<A1, A2, Catch = never> = A1 extends A2 ? A1 : Catch
*/
export type Narrow<A> = Try<A, [], NarrowRaw<A>>

export interface AnyDeepKeyAndValue {
key: string
value: any
export interface AnyDeepKeyAndValue<
K extends string = string,
V extends any = any,
> {
key: K
value: V
}

export type ArrayAccessor<TParent extends AnyDeepKeyAndValue> =
Expand Down Expand Up @@ -166,3 +169,11 @@ export type DeepValue<TValue, TAccessor> = unknown extends TValue
: TAccessor extends DeepKeys<TValue>
? DeepRecord<TValue>[TAccessor]
: never

/**
* The keys of an object or array, deeply nested and only with a value of TValue
*/
export type DeepKeysOfType<TData, TValue> = Extract<
DeepKeysAndValues<TData>,
AnyDeepKeyAndValue<string, TValue>
>['key']
72 changes: 72 additions & 0 deletions packages/form-core/tests/FormApi.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expectTypeOf, it } from 'vitest'
import { z } from 'zod'
import { FormApi } from '../src'
import type {
DeepKeys,
GlobalFormValidationError,
StandardSchemaV1Issue,
ValidationError,
Expand Down Expand Up @@ -201,3 +202,74 @@ it('should not allow setting manual errors if no validator is specified', () =>
onServer: undefined
}>
})

it('should only allow array fields for array-specific methods', () => {
type FormValues = {
name: string
age: number
startDate: Date
title: string | null | undefined
relatives: { name: string }[]
counts: (number | null | undefined)[]
}

const defaultValues: FormValues = {
name: '',
age: 0,
startDate: new Date(),
title: null,
relatives: [{ name: '' }],
counts: [5, null, undefined, 3],
}

const form = new FormApi({
defaultValues,
})
form.mount()

type AllKeys = DeepKeys<FormValues>
type OnlyArrayKeys = Extract<AllKeys, 'counts' | 'relatives'>
type RandomKeys = Extract<AllKeys, 'counts' | 'relatives' | 'title'>

const push1 = form.pushFieldValue<OnlyArrayKeys>
// @ts-expect-error too wide!
const push2 = form.pushFieldValue<AllKeys>
// @ts-expect-error too wide!
const push3 = form.pushFieldValue<RandomKeys>

const insert1 = form.insertFieldValue<OnlyArrayKeys>
// @ts-expect-error too wide!
const insert2 = form.insertFieldValue<AllKeys>
// @ts-expect-error too wide!
const insert3 = form.insertFieldValue<RandomKeys>

const replace1 = form.replaceFieldValue<OnlyArrayKeys>
// @ts-expect-error too wide!
const replace2 = form.replaceFieldValue<AllKeys>
// @ts-expect-error too wide!
const replace3 = form.replaceFieldValue<RandomKeys>

const remove1 = form.removeFieldValue<OnlyArrayKeys>
// @ts-expect-error too wide!
const remove2 = form.removeFieldValue<AllKeys>
// @ts-expect-error too wide!
const remove3 = form.removeFieldValue<RandomKeys>

const swap1 = form.swapFieldValues<OnlyArrayKeys>
// @ts-expect-error too wide!
const swap2 = form.swapFieldValues<AllKeys>
// @ts-expect-error too wide!
const swap3 = form.swapFieldValues<RandomKeys>

const move1 = form.moveFieldValues<OnlyArrayKeys>
// @ts-expect-error too wide!
const move2 = form.moveFieldValues<AllKeys>
// @ts-expect-error too wide!
const move3 = form.moveFieldValues<RandomKeys>

const validate1 = form.validateArrayFieldsStartingFrom<OnlyArrayKeys>
// @ts-expect-error too wide!
const validate2 = form.validateArrayFieldsStartingFrom<AllKeys>
// @ts-expect-error too wide!
const validate3 = form.validateArrayFieldsStartingFrom<RandomKeys>
})
101 changes: 83 additions & 18 deletions packages/form-core/tests/util-types.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { expectTypeOf } from 'vitest'
import type { DeepKeys, DeepValue } from '../src/index'
import type { DeepKeys, DeepKeysOfType, DeepValue } from '../src/index'

/**
* Properly recognizes that `0` is not an object and should not have subkeys
*/
type TupleSupport = DeepKeys<{ topUsers: [User, 0, User] }>
expectTypeOf(0 as never as TupleSupport).toEqualTypeOf<
type TupleSupport = { topUsers: [User, 0, User] }
expectTypeOf(0 as never as DeepKeys<TupleSupport>).toEqualTypeOf<
| 'topUsers'
| 'topUsers[0]'
| 'topUsers[0].name'
Expand All @@ -17,50 +17,95 @@ expectTypeOf(0 as never as TupleSupport).toEqualTypeOf<
| 'topUsers[2].id'
| 'topUsers[2].age'
>()
expectTypeOf(0 as never as DeepKeysOfType<TupleSupport, number>).toEqualTypeOf<
'topUsers[0].age' | 'topUsers[1]' | 'topUsers[2].age'
>()
expectTypeOf(0 as never as DeepKeysOfType<TupleSupport, string>).toEqualTypeOf<
'topUsers[0].name' | 'topUsers[0].id' | 'topUsers[2].name' | 'topUsers[2].id'
>()
expectTypeOf(0 as never as DeepKeysOfType<TupleSupport, User>).toEqualTypeOf<
'topUsers[0]' | 'topUsers[2]'
>()
expectTypeOf(
0 as never as DeepKeysOfType<TupleSupport, Date>,
).toEqualTypeOf<never>()

/**
* Properly recognizes that a normal number index won't cut it and should be `[number]` prefixed instead
*/
type ArraySupport = DeepKeys<{ users: User[] }>
expectTypeOf(0 as never as ArraySupport).toEqualTypeOf<
type ArraySupport = { users: User[] }
expectTypeOf(0 as never as DeepKeys<ArraySupport>).toEqualTypeOf<
| 'users'
| `users[${number}]`
| `users[${number}].name`
| `users[${number}].id`
| `users[${number}].age`
>()
expectTypeOf(
0 as never as DeepKeysOfType<ArraySupport, number>,
).toEqualTypeOf<`users[${number}].age`>()
expectTypeOf(0 as never as DeepKeysOfType<ArraySupport, string>).toEqualTypeOf<
`users[${number}].name` | `users[${number}].id`
>()
expectTypeOf(
0 as never as DeepKeysOfType<ArraySupport, User>,
).toEqualTypeOf<`users[${number}]`>()
expectTypeOf(
0 as never as DeepKeysOfType<ArraySupport, Date>,
).toEqualTypeOf<never>()

/**
* Properly handles deep object nesting like so:
*/
type NestedSupport = DeepKeys<{ meta: { mainUser: User } }>
expectTypeOf(0 as never as NestedSupport).toEqualTypeOf<
type NestedSupport = { meta: { mainUser: User } }
expectTypeOf(0 as never as DeepKeys<NestedSupport>).toEqualTypeOf<
| 'meta'
| 'meta.mainUser'
| 'meta.mainUser.name'
| 'meta.mainUser.id'
| 'meta.mainUser.age'
>()
expectTypeOf(
0 as never as DeepKeysOfType<NestedSupport, number>,
).toEqualTypeOf<`meta.mainUser.age`>()
expectTypeOf(0 as never as DeepKeysOfType<NestedSupport, string>).toEqualTypeOf<
`meta.mainUser.name` | `meta.mainUser.id`
>()
expectTypeOf(
0 as never as DeepKeysOfType<NestedSupport, User>,
).toEqualTypeOf<`meta.mainUser`>()
expectTypeOf(
0 as never as DeepKeysOfType<NestedSupport, Date>,
).toEqualTypeOf<never>()

/**
* Properly handles deep partial object nesting like so:
*/
type NestedPartialSupport = DeepKeys<{ meta?: { mainUser?: User } }>
expectTypeOf(0 as never as NestedPartialSupport).toEqualTypeOf<
type NestedPartialSupport = { meta?: { mainUser?: User } }
expectTypeOf(0 as never as DeepKeys<NestedPartialSupport>).toEqualTypeOf<
| 'meta'
| 'meta.mainUser'
| 'meta.mainUser.name'
| 'meta.mainUser.id'
| 'meta.mainUser.age'
>()
expectTypeOf(
0 as never as DeepKeysOfType<NestedPartialSupport, number>,
).toEqualTypeOf<never>()
expectTypeOf(
0 as never as DeepKeysOfType<NestedPartialSupport, number | undefined>,
).toEqualTypeOf<'meta.mainUser.age'>()

/**
* Properly handles `object` edgecase nesting like so:
*/
type ObjectNestedEdgecase = DeepKeys<{ meta: { mainUser: object } }>
expectTypeOf(0 as never as ObjectNestedEdgecase).toEqualTypeOf(
type ObjectNestedEdgecase = { meta: { mainUser: object } }
expectTypeOf(0 as never as DeepKeys<ObjectNestedEdgecase>).toEqualTypeOf(
0 as never as 'meta' | 'meta.mainUser' | `meta.mainUser.${string}`,
)
expectTypeOf(
0 as never as DeepKeysOfType<ObjectNestedEdgecase, object>,
).toEqualTypeOf<'meta' | 'meta.mainUser'>()

/**
* Properly handles `object` edgecase like so:
Expand All @@ -71,10 +116,13 @@ expectTypeOf(0 as never as ObjectEdgecase).toEqualTypeOf<string>()
/**
* Properly handles `object` edgecase nesting like so:
*/
type UnknownNestedEdgecase = DeepKeys<{ meta: { mainUser: unknown } }>
type UnknownNestedEdgecase = { meta: { mainUser: unknown } }
expectTypeOf(
0 as never as 'meta' | 'meta.mainUser' | `meta.mainUser.${string}`,
).toEqualTypeOf(0 as never as UnknownNestedEdgecase)
).toEqualTypeOf(0 as never as DeepKeys<UnknownNestedEdgecase>)
expectTypeOf(
0 as never as DeepKeysOfType<UnknownNestedEdgecase, object>,
).toEqualTypeOf<'meta'>()

/**
* Properly handles discriminated unions like so:
Expand All @@ -83,10 +131,15 @@ type DiscriminatedUnion = { name: string } & (
| { variant: 'foo' }
| { variant: 'bar'; baz: boolean }
)
type DiscriminatedUnionKeys = DeepKeys<DiscriminatedUnion>
expectTypeOf(0 as never as DiscriminatedUnionKeys).toEqualTypeOf<
expectTypeOf(0 as never as DeepKeys<DiscriminatedUnion>).toEqualTypeOf<
'name' | 'variant' | 'baz'
>()
expectTypeOf(
0 as never as DeepKeysOfType<DiscriminatedUnion, string>,
).toEqualTypeOf<'name' | 'variant'>()
expectTypeOf(
0 as never as DeepKeysOfType<DiscriminatedUnion, boolean>,
).toEqualTypeOf<'baz'>()

type DiscriminatedUnionValueShared = DeepValue<DiscriminatedUnion, 'variant'>
expectTypeOf(0 as never as DiscriminatedUnionValueShared).toEqualTypeOf<
Expand All @@ -98,10 +151,16 @@ expectTypeOf(
).toEqualTypeOf<boolean>()

/**
* Properly handles `object` edgecase like so:
* Properly handles `unknown` edgecase like so:
*/
type UnknownEdgecase = DeepKeys<unknown>
expectTypeOf(0 as never as UnknownEdgecase).toEqualTypeOf<string>()
expectTypeOf(
0 as never as DeepKeysOfType<unknown, unknown>,
).toEqualTypeOf<string>()
expectTypeOf(
0 as never as DeepKeysOfType<unknown, object>,
).toEqualTypeOf<never>()

type NestedKeysExample = DeepValue<
{ meta: { mainUser: User } },
Expand Down Expand Up @@ -364,10 +423,16 @@ type ObjectWithAny = {
}
}

type AnyObjectKeys = DeepKeys<ObjectWithAny>
expectTypeOf(0 as never as AnyObjectKeys).toEqualTypeOf<
expectTypeOf(0 as never as DeepKeys<ObjectWithAny>).toEqualTypeOf<
'a' | 'b' | 'obj' | `a.${string}` | 'obj.c' | `obj.c.${string}` | 'obj.d'
>()
// since any can also be number, It's okay to be included
expectTypeOf(0 as never as DeepKeysOfType<ObjectWithAny, number>).toEqualTypeOf<
'a' | 'b' | 'obj.c' | 'obj.d'
>()
expectTypeOf(0 as never as DeepKeysOfType<ObjectWithAny, string>).toEqualTypeOf<
'a' | 'obj.c'
>()
type AnyObjectExample = DeepValue<ObjectWithAny, 'a'>
expectTypeOf(0 as never as AnyObjectExample).toEqualTypeOf<any>()
type AnyObjectExample2 = DeepValue<ObjectWithAny, 'b'>
Expand Down