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
15 changes: 15 additions & 0 deletions .changeset/fix-form-options-validator-inference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@tanstack/form-core': patch
---

fix(form-core): fix validator type inference in formOptions

When using `formOptions()` with `validators`, the callback parameter types were
inferred as `any` instead of the expected typed shape. This happened because the
return type `TOptions` captured the raw object literal type before TypeScript could
resolve the validator generics.

The fix uses `Omit` + `Pick` to selectively re-type the validator-dependent properties
(`validators`, `onSubmit`, `onSubmitInvalid`, `listeners`) with the properly resolved
generic types, while preserving spread/override compatibility through `TOptions` for
all other properties.
34 changes: 32 additions & 2 deletions packages/form-core/src/formOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,19 @@ inside of it, meaning that TFormData changes to `unknown`.

To bypass this, the intersection for defaultOpts gives TypeScript that information again,
without losing the benefits from the TOptions generic.

The return type uses Pick to selectively overlay the resolved validator/listener/onSubmit
types onto TOptions. This preserves spread compatibility while ensuring callbacks
have correctly typed parameters. We avoid intersecting the full FormOptions to prevent
"excessively deep" errors from `in out` variance annotations.
*/

/**
* Keys on FormOptions whose types depend on the validator generics
* and need to be resolved for proper callback parameter inference.
*/
type FormOptionsDependentKeys = 'validators' | 'onSubmit' | 'onSubmitInvalid' | 'listeners'

export function formOptions<
TOptions extends Partial<
FormOptions<
Expand Down Expand Up @@ -67,6 +78,25 @@ export function formOptions<
>
> &
TOptions,
): TOptions {
return defaultOpts
): Omit<TOptions, FormOptionsDependentKeys> &
Pick<
Partial<
FormOptions<
NoInfer<TFormData>,
NoInfer<TOnMount>,
NoInfer<TOnChange>,
NoInfer<TOnChangeAsync>,
NoInfer<TOnBlur>,
NoInfer<TOnBlurAsync>,
NoInfer<TOnSubmit>,
NoInfer<TOnSubmitAsync>,
NoInfer<TOnDynamic>,
NoInfer<TOnDynamicAsync>,
NoInfer<TOnServer>,
NoInfer<TSubmitMeta>
>
>,
FormOptionsDependentKeys & keyof TOptions
> {
Comment on lines +81 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In TypeScript, does Pick<Partial<{ onSubmit: () => void }>, "onSubmit">keeponSubmitoptional, and does{ onSubmit?: () => void }["onSubmit"]includeundefined?

💡 Result:

Yes on both points. 1) Pick<Partial<{ onSubmit: => void }>, "onSubmit"> keeps onSubmit optional. - Partial makes every property optional: it “set[s] … properties … to optional” [1]. - Pick preserves whether a property is optional in the source type because it “picks” those named properties from the given type [1]. 2) { onSubmit?: => void }["onSubmit"] includes undefined. - An optional property onSubmit?: ... is treated as having the possibility of being absent, so indexing its property type yields a union with undefined (optional implies “possibly undefined”). Practical equivalence: - Pick<Partial<{ onSubmit: => void }>, "onSubmit"> behaves like { onSubmit?: => void }. - Therefore, ({ onSubmit?: => void })["onSubmit"] is ( => void) | undefined.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/form-core/src/formOptions.ts"

# Show the relevant section around the reported lines
sed -n '1,140p' "$FILE" | cat -n

# Locate FormOptionsDependentKeys definition and any related helpers/usages
rg -n "FormOptionsDependentKeys" "$FILE"

Repository: TanStack/form

Length of output: 3988


Preserve presence/optionality of dependent callback keys in formOptions return type
formOptions reintroduces validators/onSubmit/onSubmitInvalid/listeners via Pick<Partial<FormOptions<...>>, ...>, and Partial makes those keys optional—so even when TOptions supplies them, the return type can widen them to ... | undefined (e.g. onSubmit?: ...).
Replace the Pick<Partial<...>> overlay with a mapped overlay that preserves optionality from TOptions while using the resolved callback types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/form-core/src/formOptions.ts` around lines 81 - 100, The return type
currently reintroduces dependent keys via Pick<Partial<FormOptions<...>>,
FormOptionsDependentKeys & keyof TOptions>, which forces those keys to be
optional; replace that Partial+Pick overlay with a mapped type that preserves
the original optionality from TOptions while mapping each key to the resolved
callback type from FormOptions. Concretely, instead of Pick<Partial<...>, ...>,
create a mapped overlay like: for each K in FormOptionsDependentKeys & keyof
TOptions produce a property K whose type is the corresponding resolved
FormOptions[K] but marked optional only when TOptions has K as optional (use a
conditional check like "undefined extends TOptions[K]" to decide whether to
append | undefined or make the property optional), and reuse the existing
FormOptions<...> instantiation to derive the resolved callback types; update the
signature in formOptions to use this new mapped overlay so presence/optionality
from TOptions is preserved.

return defaultOpts as any
}
67 changes: 67 additions & 0 deletions packages/form-core/tests/formOptions.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,71 @@ describe('formOptions', () => {
('Too short!' | 'I just need an error')[]
>()
})

it('validators.onSubmit data param should NOT be any when defined in formOptions (#1613)', () => {
type FormData = {
description: string
}

const formOpts = formOptions({
defaultValues: {
description: '',
} as FormData,
validators: {
onSubmit: (data) => {
// The core of #1613: data.value should be FormData, NOT any
expectTypeOf(data.value).toEqualTypeOf<FormData>()
expectTypeOf(data.value).not.toBeAny()

if (data.value.description.length === 0) {
return 'Description is required'
}
return undefined
},
},
})

const form = new FormApi(formOpts)
expectTypeOf(form.state.values).toEqualTypeOf<FormData>()

// Also verify spreading still works
const form2 = new FormApi({ ...formOpts })
expectTypeOf(form2.state.values).toEqualTypeOf<FormData>()
})

it('onSubmit handler data param should NOT be any when defined in formOptions (#1613)', () => {
type FormData = {
rows: { id: string }[]
}

const formOpts = formOptions({
defaultValues: {
rows: [] as { id: string }[],
} as FormData,
onSubmit: (data) => {
// Verify the onSubmit handler parameter is typed
expectTypeOf(data.value).toEqualTypeOf<FormData>()
expectTypeOf(data.value).not.toBeAny()
},
validators: {
onSubmit: (data) => {
expectTypeOf(data.value).toEqualTypeOf<FormData>()
expectTypeOf(data.value).not.toBeAny()
if (data.value.rows.length < 2) {
return 'Need at least 2 rows'
}
return undefined
},
},
})

// Should work without type errors when passed to FormApi
const form = new FormApi(formOpts)
expectTypeOf(form.state.values).toEqualTypeOf<FormData>()

// Spreading should also work
const form2 = new FormApi({ ...formOpts })
expectTypeOf(form2.state.values).toEqualTypeOf<FormData>()
})
})