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
5 changes: 5 additions & 0 deletions .changeset/silver-mails-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

Prevent synchronous validators from returning Promises
17 changes: 11 additions & 6 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -309,13 +314,13 @@ export interface FieldValidators<
/**
* An optional function, that runs on the mount event of input.
*/
onMount?: TOnMount
onMount?: RejectPromiseValidator<TOnMount>
/**
* An optional function, that runs on the change event of input.
*
* @example z.string().min(1)
*/
onChange?: TOnChange
onChange?: RejectPromiseValidator<TOnChange>
/**
* An optional property similar to `onChange` but async validation
*
Expand All @@ -337,7 +342,7 @@ export interface FieldValidators<
*
* @example z.string().min(1)
*/
onBlur?: TOnBlur
onBlur?: RejectPromiseValidator<TOnBlur>
/**
* An optional property similar to `onBlur` but async validation.
*
Expand All @@ -360,14 +365,14 @@ export interface FieldValidators<
*
* @example z.string().min(1)
*/
onSubmit?: TOnSubmit
onSubmit?: RejectPromiseValidator<TOnSubmit>
/**
* 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<TOnDynamic>
onDynamicAsync?: TOnDynamicAsync
onDynamicAsyncDebounceMs?: number
}
Expand Down
17 changes: 11 additions & 6 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -186,11 +191,11 @@ export interface FormValidators<
/**
* Optional function that fires as soon as the component mounts.
*/
onMount?: TOnMount
onMount?: RejectPromiseValidator<TOnMount>
/**
* Optional function that checks the validity of your data whenever a value changes
*/
onChange?: TOnChange
onChange?: RejectPromiseValidator<TOnChange>
/**
* Optional onChange asynchronous counterpart to onChange. Useful for more complex validation logic that might involve server requests.
*/
Expand All @@ -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<TOnBlur>
/**
* Optional onBlur asynchronous validation method for when a field loses focus returns a ` FormValidationError` or a promise of `Promise<FormValidationError>`
*/
Expand All @@ -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<TOnSubmit>
onSubmitAsync?: TOnSubmitAsync
onDynamic?: TOnDynamic
onDynamic?: RejectPromiseValidator<TOnDynamic>
onDynamicAsync?: TOnDynamicAsync
onDynamicAsyncDebounceMs?: number
}
Expand Down
17 changes: 17 additions & 0 deletions packages/form-core/src/util-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
*/
export type UnwrapOneLevelOfArray<T> = 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> = 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<any> | PromiseLike<any>]
? never
: T
: T
Comment on lines +13 to +21
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this truly needed? Can the return type not be Omit<T, Promise<any> | PromiseLike<any>>?

Copy link
Author

Choose a reason for hiding this comment

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

I'm assuming you mean Exclude rather than Omit, but no that does not work here. I believe it is a limitation of TypeScript, but the type is still treated as unknown it seems even if attempting to approach it from this angle.


type Narrowable = string | number | bigint | boolean

type NarrowRaw<A> =
Expand Down
26 changes: 24 additions & 2 deletions packages/form-core/tests/FieldApi.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expectTypeOf, it } from 'vitest'
import { z } from 'zod'
import { FieldApi, FormApi } from '../src/index'
import type { StandardSchemaV1Issue } from '../src/index'
import { FieldApi, FormApi } from '../src'
import type { StandardSchemaV1Issue } from '../src'

it('should type value properly', () => {
const form = new FormApi({
Expand Down Expand Up @@ -460,3 +460,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'),
},
})
})
16 changes: 16 additions & 0 deletions packages/form-core/tests/FormApi.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
})
})
Loading