Skip to content

Commit 5924423

Browse files
committed
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`.
1 parent 623dccc commit 5924423

File tree

7 files changed

+89
-15
lines changed

7 files changed

+89
-15
lines changed

.changeset/silver-mails-play.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
Prevent synchronous validators from returning Promises

docs/reference/type-aliases/UnwrapFieldValidateOrFn.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ title: UnwrapFieldValidateOrFn
99
type UnwrapFieldValidateOrFn<TName, TValidateOrFn, TFormValidateOrFn> =
1010
| [TFormValidateOrFn] extends [StandardSchemaV1<any, infer TStandardOut>] ? TName extends keyof TStandardOut ? StandardSchemaV1Issue[] : undefined : undefined
1111
| UnwrapFormValidateOrFnForInner<TFormValidateOrFn> extends infer TFormValidateVal ? TFormValidateVal extends object ? [DeepValue<TFormValidateVal, TName>] extends [never] ? undefined : StandardSchemaV1Issue[] : TFormValidateVal extends object ? TName extends keyof TFormValidateVal["fields"] ? TFormValidateVal["fields"][TName] : undefined : undefined : never
12-
| [TValidateOrFn] extends [FieldValidateFn<any, any, any>] ? ReturnType<TValidateOrFn> : [TValidateOrFn] extends [StandardSchemaV1<any, any>] ? StandardSchemaV1Issue[] : undefined;
12+
| [TValidateOrFn] extends [FieldValidateFn<any, any, any, any>] ? ReturnType<TValidateOrFn> : [TValidateOrFn] extends [StandardSchemaV1<any, any>] ? StandardSchemaV1Issue[] : undefined;
1313
```
1414

1515
Defined in: [packages/form-core/src/FieldApi.ts:135](https://github.com/TanStack/form/blob/main/packages/form-core/src/FieldApi.ts#L135)

packages/form-core/src/FieldApi.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
mergeOpts,
1414
} from './utils'
1515
import { defaultValidationLogic } from './ValidationLogic'
16-
import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types'
16+
import type {
17+
DeepKeys,
18+
DeepValue,
19+
RejectPromiseValidator,
20+
UnwrapOneLevelOfArray,
21+
} from './util-types'
1722
import type {
1823
StandardSchemaV1,
1924
StandardSchemaV1Issue,
@@ -309,13 +314,13 @@ export interface FieldValidators<
309314
/**
310315
* An optional function, that runs on the mount event of input.
311316
*/
312-
onMount?: TOnMount
317+
onMount?: RejectPromiseValidator<TOnMount>
313318
/**
314319
* An optional function, that runs on the change event of input.
315320
*
316321
* @example z.string().min(1)
317322
*/
318-
onChange?: TOnChange
323+
onChange?: RejectPromiseValidator<TOnChange>
319324
/**
320325
* An optional property similar to `onChange` but async validation
321326
*
@@ -337,7 +342,7 @@ export interface FieldValidators<
337342
*
338343
* @example z.string().min(1)
339344
*/
340-
onBlur?: TOnBlur
345+
onBlur?: RejectPromiseValidator<TOnBlur>
341346
/**
342347
* An optional property similar to `onBlur` but async validation.
343348
*
@@ -360,14 +365,14 @@ export interface FieldValidators<
360365
*
361366
* @example z.string().min(1)
362367
*/
363-
onSubmit?: TOnSubmit
368+
onSubmit?: RejectPromiseValidator<TOnSubmit>
364369
/**
365370
* An optional property similar to `onSubmit` but async validation.
366371
*
367372
* @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' })
368373
*/
369374
onSubmitAsync?: TOnSubmitAsync
370-
onDynamic?: TOnDynamic
375+
onDynamic?: RejectPromiseValidator<TOnDynamic>
371376
onDynamicAsync?: TOnDynamicAsync
372377
onDynamicAsyncDebounceMs?: number
373378
}

packages/form-core/src/FormApi.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ import type {
4747
ValidationErrorMap,
4848
ValidationErrorMapKeys,
4949
} from './types'
50-
import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types'
50+
import type {
51+
DeepKeys,
52+
DeepKeysOfType,
53+
DeepValue,
54+
RejectPromiseValidator,
55+
} from './util-types'
5156
import type { Updater } from './utils'
5257

5358
/**
@@ -186,11 +191,11 @@ export interface FormValidators<
186191
/**
187192
* Optional function that fires as soon as the component mounts.
188193
*/
189-
onMount?: TOnMount
194+
onMount?: RejectPromiseValidator<TOnMount>
190195
/**
191196
* Optional function that checks the validity of your data whenever a value changes
192197
*/
193-
onChange?: TOnChange
198+
onChange?: RejectPromiseValidator<TOnChange>
194199
/**
195200
* Optional onChange asynchronous counterpart to onChange. Useful for more complex validation logic that might involve server requests.
196201
*/
@@ -202,7 +207,7 @@ export interface FormValidators<
202207
/**
203208
* Optional function that validates the form data when a field loses focus, returns a `FormValidationError`
204209
*/
205-
onBlur?: TOnBlur
210+
onBlur?: RejectPromiseValidator<TOnBlur>
206211
/**
207212
* Optional onBlur asynchronous validation method for when a field loses focus returns a ` FormValidationError` or a promise of `Promise<FormValidationError>`
208213
*/
@@ -211,9 +216,9 @@ export interface FormValidators<
211216
* 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.
212217
*/
213218
onBlurAsyncDebounceMs?: number
214-
onSubmit?: TOnSubmit
219+
onSubmit?: RejectPromiseValidator<TOnSubmit>
215220
onSubmitAsync?: TOnSubmitAsync
216-
onDynamic?: TOnDynamic
221+
onDynamic?: RejectPromiseValidator<TOnDynamic>
217222
onDynamicAsync?: TOnDynamicAsync
218223
onDynamicAsyncDebounceMs?: number
219224
}

packages/form-core/src/util-types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@
33
*/
44
export type UnwrapOneLevelOfArray<T> = T extends (infer U)[] ? U : T
55

6+
/**
7+
* @private
8+
* Helper type that rejects synchronous validators that return Promises.
9+
* If the validator is a function returning a Promise, the type becomes `never`.
10+
* Uses infer to extract the return type and handles `any` specially to avoid
11+
* rejecting mock functions and other uses of `any`.
12+
*/
13+
export type RejectPromiseValidator<T> = T extends (...args: any[]) => infer R
14+
? // Check if R is `any` - if so, allow it (common with mocks)
15+
0 extends 1 & R
16+
? T
17+
: // Otherwise, reject if it's a Promise
18+
[R] extends [Promise<any> | PromiseLike<any>]
19+
? never
20+
: T
21+
: T
22+
623
type Narrowable = string | number | bigint | boolean
724

825
type NarrowRaw<A> =

packages/form-core/tests/FieldApi.test-d.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { expectTypeOf, it } from 'vitest'
22
import { z } from 'zod'
3-
import { FieldApi, FormApi } from '../src/index'
4-
import type { StandardSchemaV1Issue } from '../src/index'
3+
import {
4+
FieldApi,
5+
FieldValidateFn,
6+
FormApi,
7+
StandardSchemaV1Issue,
8+
} from '../src/index'
59

610
it('should type value properly', () => {
711
const form = new FormApi({
@@ -460,3 +464,25 @@ it('should allow setting manual errors with standard schema validators on the fi
460464
onDynamic: undefined
461465
}>
462466
})
467+
468+
it('should not allow promises to be returned from synchronous validators', () => {
469+
const form = new FormApi({
470+
defaultValues: { name: '' },
471+
})
472+
473+
// This should cause a type error - Promise returns are not allowed in sync validators
474+
new FieldApi({
475+
form,
476+
name: 'name',
477+
validators: {
478+
// @ts-expect-error synchronous validators should not return promises
479+
onBlur: () => Promise.resolve('error'),
480+
// @ts-expect-error synchronous validators should not return promises
481+
onChange: () => Promise.resolve('error'),
482+
// @ts-expect-error synchronous validators should not return promises
483+
onDynamic: () => Promise.resolve('error'),
484+
// @ts-expect-error synchronous validators should not return promises
485+
onChange: () => Promise.resolve('error'),
486+
},
487+
})
488+
})

packages/form-core/tests/FormApi.test-d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,19 @@ it('listeners should be typed correctly when using formOptions', () => {
447447

448448
form.handleSubmit()
449449
})
450+
451+
it('should not allow promises to be returned from synchronous validators', () => {
452+
const form = new FormApi({
453+
defaultValues: { name: '' },
454+
validators: {
455+
// @ts-expect-error synchronous validators should not return promises
456+
onBlur: () => Promise.resolve('error'),
457+
// @ts-expect-error synchronous validators should not return promises
458+
onChange: () => Promise.resolve('error'),
459+
// @ts-expect-error synchronous validators should not return promises
460+
onDynamic: () => Promise.resolve('error'),
461+
// @ts-expect-error synchronous validators should not return promises
462+
onChange: () => Promise.resolve('error'),
463+
},
464+
})
465+
})

0 commit comments

Comments
 (0)