From 21983623c28d5ff0ebc9c433839da15adcc81232 Mon Sep 17 00:00:00 2001 From: Matt Huggins Date: Fri, 9 Jan 2026 09:31:05 -0600 Subject: [PATCH 1/2] fix(form-core): Prevent synchronous validators from returning Promises Passing an asynchronous function to a synchronous validator (i.e.: `onBlur`, `onChange`, `onDynamic`, `onSubmit`) is a bit of a foot-gun given that it does not produce any typescript errors, but it also results in the form/field validation function running after the core validation logic. To prevent this, update the types for these validator functions on both `FormApi` and `FieldApi` to prevent passing a function that returns a `Promise`. --- .changeset/silver-mails-play.md | 5 ++++ packages/form-core/src/FieldApi.ts | 17 +++++++----- packages/form-core/src/FormApi.ts | 17 +++++++----- packages/form-core/src/util-types.ts | 17 ++++++++++++ packages/form-core/tests/FieldApi.test-d.ts | 30 +++++++++++++++++++-- packages/form-core/tests/FormApi.test-d.ts | 16 +++++++++++ 6 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 .changeset/silver-mails-play.md diff --git a/.changeset/silver-mails-play.md b/.changeset/silver-mails-play.md new file mode 100644 index 000000000..88da27729 --- /dev/null +++ b/.changeset/silver-mails-play.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Prevent synchronous validators from returning Promises diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 1d5ba3096..c8765967f 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -13,7 +13,12 @@ import { mergeOpts, } from './utils' import { defaultValidationLogic } from './ValidationLogic' -import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types' +import type { + DeepKeys, + DeepValue, + RejectPromiseValidator, + UnwrapOneLevelOfArray, +} from './util-types' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -309,13 +314,13 @@ export interface FieldValidators< /** * An optional function, that runs on the mount event of input. */ - onMount?: TOnMount + onMount?: RejectPromiseValidator /** * An optional function, that runs on the change event of input. * * @example z.string().min(1) */ - onChange?: TOnChange + onChange?: RejectPromiseValidator /** * An optional property similar to `onChange` but async validation * @@ -337,7 +342,7 @@ export interface FieldValidators< * * @example z.string().min(1) */ - onBlur?: TOnBlur + onBlur?: RejectPromiseValidator /** * An optional property similar to `onBlur` but async validation. * @@ -360,14 +365,14 @@ export interface FieldValidators< * * @example z.string().min(1) */ - onSubmit?: TOnSubmit + onSubmit?: RejectPromiseValidator /** * An optional property similar to `onSubmit` but async validation. * * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) */ onSubmitAsync?: TOnSubmitAsync - onDynamic?: TOnDynamic + onDynamic?: RejectPromiseValidator onDynamicAsync?: TOnDynamicAsync onDynamicAsyncDebounceMs?: number } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ff52bbe74..10298c706 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -47,7 +47,12 @@ import type { ValidationErrorMap, ValidationErrorMapKeys, } from './types' -import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' +import type { + DeepKeys, + DeepKeysOfType, + DeepValue, + RejectPromiseValidator, +} from './util-types' import type { Updater } from './utils' /** @@ -186,11 +191,11 @@ export interface FormValidators< /** * Optional function that fires as soon as the component mounts. */ - onMount?: TOnMount + onMount?: RejectPromiseValidator /** * Optional function that checks the validity of your data whenever a value changes */ - onChange?: TOnChange + onChange?: RejectPromiseValidator /** * Optional onChange asynchronous counterpart to onChange. Useful for more complex validation logic that might involve server requests. */ @@ -202,7 +207,7 @@ export interface FormValidators< /** * Optional function that validates the form data when a field loses focus, returns a `FormValidationError` */ - onBlur?: TOnBlur + onBlur?: RejectPromiseValidator /** * Optional onBlur asynchronous validation method for when a field loses focus returns a ` FormValidationError` or a promise of `Promise` */ @@ -211,9 +216,9 @@ export interface FormValidators< * The default time in milliseconds that if set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds. */ onBlurAsyncDebounceMs?: number - onSubmit?: TOnSubmit + onSubmit?: RejectPromiseValidator onSubmitAsync?: TOnSubmitAsync - onDynamic?: TOnDynamic + onDynamic?: RejectPromiseValidator onDynamicAsync?: TOnDynamicAsync onDynamicAsyncDebounceMs?: number } diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index 8181dc920..a27da3a92 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -3,6 +3,23 @@ */ export type UnwrapOneLevelOfArray = T extends (infer U)[] ? U : T +/** + * @private + * Helper type that rejects synchronous validators that return Promises. + * If the validator is a function returning a Promise, the type becomes `never`. + * Uses infer to extract the return type and handles `any` specially to avoid + * rejecting mock functions and other uses of `any`. + */ +export type RejectPromiseValidator = T extends (...args: any[]) => infer R + ? // Check if R is `any` - if so, allow it (common with mocks) + 0 extends 1 & R + ? T + : // Otherwise, reject if it's a Promise + [R] extends [Promise | PromiseLike] + ? never + : T + : T + type Narrowable = string | number | bigint | boolean type NarrowRaw = diff --git a/packages/form-core/tests/FieldApi.test-d.ts b/packages/form-core/tests/FieldApi.test-d.ts index 838b5c4e1..deaba36eb 100644 --- a/packages/form-core/tests/FieldApi.test-d.ts +++ b/packages/form-core/tests/FieldApi.test-d.ts @@ -1,7 +1,11 @@ import { expectTypeOf, it } from 'vitest' import { z } from 'zod' -import { FieldApi, FormApi } from '../src/index' -import type { StandardSchemaV1Issue } from '../src/index' +import { + FieldApi, + FieldValidateFn, + FormApi, + StandardSchemaV1Issue, +} from '../src/index' it('should type value properly', () => { const form = new FormApi({ @@ -460,3 +464,25 @@ it('should allow setting manual errors with standard schema validators on the fi onDynamic: undefined }> }) + +it('should not allow promises to be returned from synchronous validators', () => { + const form = new FormApi({ + defaultValues: { name: '' }, + }) + + // This should cause a type error - Promise returns are not allowed in sync validators + new FieldApi({ + form, + name: 'name', + validators: { + // @ts-expect-error synchronous validators should not return promises + onBlur: () => Promise.resolve('error'), + // @ts-expect-error synchronous validators should not return promises + onChange: () => Promise.resolve('error'), + // @ts-expect-error synchronous validators should not return promises + onDynamic: () => Promise.resolve('error'), + // @ts-expect-error synchronous validators should not return promises + onChange: () => Promise.resolve('error'), + }, + }) +}) diff --git a/packages/form-core/tests/FormApi.test-d.ts b/packages/form-core/tests/FormApi.test-d.ts index 1d2554a6e..7543e0420 100644 --- a/packages/form-core/tests/FormApi.test-d.ts +++ b/packages/form-core/tests/FormApi.test-d.ts @@ -447,3 +447,19 @@ it('listeners should be typed correctly when using formOptions', () => { form.handleSubmit() }) + +it('should not allow promises to be returned from synchronous validators', () => { + const form = new FormApi({ + defaultValues: { name: '' }, + validators: { + // @ts-expect-error synchronous validators should not return promises + onBlur: () => Promise.resolve('error'), + // @ts-expect-error synchronous validators should not return promises + onChange: () => Promise.resolve('error'), + // @ts-expect-error synchronous validators should not return promises + onDynamic: () => Promise.resolve('error'), + // @ts-expect-error synchronous validators should not return promises + onChange: () => Promise.resolve('error'), + }, + }) +}) From 6b927d26f97c21ff57a1afd5b40ef29abca91569 Mon Sep 17 00:00:00 2001 From: Matt Huggins Date: Sat, 10 Jan 2026 07:53:16 -0600 Subject: [PATCH 2/2] fixup! ci: Version Packages (#1977) --- packages/form-core/tests/FieldApi.test-d.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/form-core/tests/FieldApi.test-d.ts b/packages/form-core/tests/FieldApi.test-d.ts index deaba36eb..bd1ec4e37 100644 --- a/packages/form-core/tests/FieldApi.test-d.ts +++ b/packages/form-core/tests/FieldApi.test-d.ts @@ -1,11 +1,7 @@ import { expectTypeOf, it } from 'vitest' import { z } from 'zod' -import { - FieldApi, - FieldValidateFn, - FormApi, - StandardSchemaV1Issue, -} from '../src/index' +import { FieldApi, FormApi } from '../src' +import type { StandardSchemaV1Issue } from '../src' it('should type value properly', () => { const form = new FormApi({