diff --git a/.changeset/fix-form-options-validator-inference.md b/.changeset/fix-form-options-validator-inference.md new file mode 100644 index 000000000..0cf6dac9c --- /dev/null +++ b/.changeset/fix-form-options-validator-inference.md @@ -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. diff --git a/packages/form-core/src/formOptions.ts b/packages/form-core/src/formOptions.ts index e77460e6c..acd88f6bb 100644 --- a/packages/form-core/src/formOptions.ts +++ b/packages/form-core/src/formOptions.ts @@ -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< @@ -67,6 +78,25 @@ export function formOptions< > > & TOptions, -): TOptions { - return defaultOpts +): Omit & + Pick< + Partial< + FormOptions< + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer + > + >, + FormOptionsDependentKeys & keyof TOptions + > { + return defaultOpts as any } diff --git a/packages/form-core/tests/formOptions.test-d.ts b/packages/form-core/tests/formOptions.test-d.ts index fd8089549..79b2a1cc7 100644 --- a/packages/form-core/tests/formOptions.test-d.ts +++ b/packages/form-core/tests/formOptions.test-d.ts @@ -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() + 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() + + // Also verify spreading still works + const form2 = new FormApi({ ...formOpts }) + expectTypeOf(form2.state.values).toEqualTypeOf() + }) + + 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() + expectTypeOf(data.value).not.toBeAny() + }, + validators: { + onSubmit: (data) => { + expectTypeOf(data.value).toEqualTypeOf() + 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() + + // Spreading should also work + const form2 = new FormApi({ ...formOpts }) + expectTypeOf(form2.state.values).toEqualTypeOf() + }) }) +