Zod's async `refine` / `superRefine` called twice when used with TanStack Form's `onSubmitAsync` validator
Describe the bug
When using a Zod schema with an asynchronous refine or superRefine validation directly within TanStack Form's onSubmitAsync validator option, the asynchronous function (which performs a network request in this case) appears to be executed twice in first submission attempt. This results in duplicate network request being visible in the browser's Network tab. The subsequent submission attempts runs only once.
Your minimal, reproducible example
https://codesandbox.io/p/sandbox/serene-brook-gmt73s
Steps to reproduce
-
Define a Zod schema with async validation that performs a network request:
Using
refine:import { z } from 'zod'; // Assume checkUser is an async function that performs a network request // It might fetch data or post to an endpoint, throwing on failure. // async function checkUser(username: string): Promise<void> { /* ... network request ... */ } export const asyncIgnoredUsersSchemaRefine = z.object({ ignoredUser: z.string().refine(async value => { console.log('Attempting async refine check for:', value); // For debugging context try { // This function triggers a network request await checkUser(value); return true; } catch { return false; } }, 'The user does not exist') });Using
superRefine:import { z } from 'zod'; // Assume checkUser performs a network request // async function checkUser(username: string): Promise<void> { /* ... network request ... */ } export const asyncIgnoredUsersSchemaSuperRefine = z.object({ ignoredUser: z.string().superRefine(async (arg, ctx) => { console.log('Attempting async superRefine check for:', arg); // For debugging context try { // This function triggers a network request await checkUser(arg); } catch (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: (error as Error).message }); } }) }); -
Use the schema in
useAppForm(oruseForm)onSubmitAsync:import { useAppForm } from './path/to/useAppForm'; // Or useForm from @tanstack/react-form import { asyncIgnoredUsersSchemaRefine } from './path/to/schema'; // Assume getIgnoredUsersSchema(data) returns a synchronous Zod schema // Assume data is available // Assume mutatePartial is defined // ... inside your component const form = useAppForm({ defaultValues: { ignoredUser: '' }, validators: { onSubmit: getIgnoredUsersSchema(data), // Synchronous validation onSubmitAsync: asyncIgnoredUsersSchemaRefine // <--- Problematic usage // Using asyncIgnoredUsersSchemaSuperRefine here shows the same duplicate network request }, onSubmit: ({ value: { ignoredUser }, formApi }) => { mutatePartial(oldData => ({ ignoredUsers: [...oldData.ignoredUsers, ignoredUser.trim()] })); formApi.reset(); } }); // ... rest of the component (render form, etc.) -
Trigger form submission: Open the browser's Network tab, fill the
ignoredUserfield, and submit the form.
Expected behavior
The asynchronous validation logic within refine or superRefine should execute only once. Consequently, the network request initiated by the checkUser function should appear only once in the Network tab per submission attempt.
How often does this bug happen?
Every time
Screenshots or Videos
No response
Platform
- OS: Windows
- Browser: Brave
- Version: 1.77.97 - Chromium: 135.0.7049.84
TanStack Form adapter
None
TanStack Form version
1.6.3
TypeScript version
5.8.3
Additional context
Actual Behavior:
The network request initiated by the checkUser function (triggered via the async refine or superRefine) appears twice in the browser's Network tab for a single form submission. The console logs (if added) might also appear twice, confirming the duplicate execution.
Workaround:
A workaround involves manually calling Zod's safeParseAsync method within the onSubmitAsync function body, instead of passing the schema directly. This prevents the double execution and thus the duplicate network request.
import { useAppForm } from './path/to/useAppForm';
import { asyncIgnoredUsersSchemaRefine } from './path/to/schema';
import { z, ZodIssue } from 'zod';
// Assume getIgnoredUsersSchema(data) returns a synchronous Zod schema
// Assume data is available
// Assume mutatePartial is defined
// Helper function (as provided in the original report)
function prefixSchemaToErrors(issues: readonly ZodIssue[]) {
const schema = new Map<string, ZodIssue[]>();
for (const issue of issues) {
const path = [...issue.path]
.map(segment => (typeof segment === 'number' ? `[${segment}]` : segment))
.join('.')
.replace(/\.\[/g, '[');
schema.set(path, (schema.get(path) ?? []).concat(issue));
}
return Object.fromEntries(schema);
}
// ... inside your component
const form = useAppForm({
defaultValues: {
ignoredUser: ''
},
validators: {
onSubmit: getIgnoredUsersSchema(data),
onSubmitAsync: async ({ value }) => { // <--- Workaround implementation
console.log('Manually triggering safeParseAsync');
// This validation triggers the network request via checkUser
const validation = await asyncIgnoredUsersSchemaRefine.safeParseAsync(value);
// Check network tab: the request from checkUser should appear only once now.
if (!validation.success) {
const schemaErrors = prefixSchemaToErrors(validation.error.issues);
// TanStack Form expects errors in a specific format
// Adjust structure based on TanStack Form version if needed
return {
form: schemaErrors , // General form error
field: schemaErrors // Field-specific errors
};
}
// Return undefined or void if validation passes
return undefined;
}
},
onSubmit: ({ value: { ignoredUser }, formApi }) => {
mutatePartial(oldData => ({ ignoredUsers: [...oldData.ignoredUsers, ignoredUser.trim()] }));
formApi.reset();
}
});
// ... rest of the component
(Note: The error formatting in the workaround might need adjustment based on the specific TanStack Form version).
Possible Cause:
This seems related to how TanStack Form internally handles or invokes Zod schemas provided to onSubmitAsync. It might be triggering the schema's asynchronous validation process more than once, leading to the unintended duplicate side effect (the network request).
A note about the workaround:
You can use the parseValuesWithSchemaAsync method to get formatted errors more easily. See https://tanstack.com/form/latest/docs/reference/classes/fieldapi#parsevaluewithschemaasync for reference
onSubmitAsync: async ({ value, formApi }) => { // <--- Workaround implementation
console.log('Manually triggering safeParseAsync');
// This validation triggers the network request via checkUser
return await formApi.parseValuesWithSchemaAsync(asyncIgnoredUsersSchemaRefine);
}
Edit
This appears to also be an issue of onBlurAsync and onChangeAsync.
Test case below:
it('should only run onSubmitAsync schemas once on submit', async () => {
vi.useFakeTimers()
const asyncCallMock = vi.fn()
const schema = z.object({
name: z.string().refine(async () => {
asyncCallMock()
return true
}, 'Some error message'),
})
const form = new FormApi({
defaultValues: {
name: '',
},
validators: {
onSubmitAsync: schema,
},
})
form.mount()
form.handleSubmit()
await vi.runAllTimersAsync()
expect(asyncCallMock).toHaveBeenCalledOnce()
})
A note about the workaround:
You can use the
parseValuesWithSchemaAsyncmethod to get formatted errors more easily. See https://tanstack.com/form/latest/docs/reference/classes/fieldapi#parsevaluewithschemaasync for referenceonSubmitAsync: async ({ value, formApi }) => { // <--- Workaround implementation console.log('Manually triggering safeParseAsync'); // This validation triggers the network request via checkUser return await formApi.parseValuesWithSchemaAsync(asyncIgnoredUsersSchemaRefine); } Edit
This appears to also be an issue of
onBlurAsyncandonChangeAsync. Test case below:it('should only run onSubmitAsync schemas once on submit', async () => { vi.useFakeTimers() const asyncCallMock = vi.fn()
const schema = z.object({ name: z.string().refine(async () => { asyncCallMock() return true }, 'Some error message'), }) const form = new FormApi({ defaultValues: { name: '', }, validators: { onSubmitAsync: schema, }, }) form.mount() form.handleSubmit() await vi.runAllTimersAsync() expect(asyncCallMock).toHaveBeenCalledOnce()})
I have also tried with that function, but it seems that it also happens. Maybe the problem is related?
I did my own helper function:
const prefixSchemaToErrors = (issues: readonly ZodIssue[]) => {
const schema = new Map<string, ZodIssue[]>()
for (const issue of issues) {
const path = [...issue.path]
.map(segment => (typeof segment === 'number' ? `[${segment}]` : segment))
.join('.')
.replace(/\.\[/g, '[')
schema.set(path, (schema.get(path) ?? []).concat(issue))
}
return Object.fromEntries(schema)
}
export const checkSchemaOnSubmitAsync =
<TFormData>(schema: ZodSchema<TFormData>): FormValidateAsyncFn<TFormData> =>
async ({ value }) => {
const validation = await schema.safeParseAsync(value)
if (!validation.success) {
const schemaErrors = prefixSchemaToErrors(validation.error.issues)
return {
form: schemaErrors,
fields: schemaErrors
}
}
}
Interesting ... that should narrow it down quite a bit.
Complete Workaround Implementation
For anyone experiencing the double execution of async refine/superRefine when used directly in TanStack Form's async validators (like onSubmitAsync), here is a workaround that manually invokes Zod's safeParseAsync. This avoids passing the schema directly to TanStack Form and seems to prevent the duplicate execution.
1. Helper Functions & Factories:
import { z, ZodIssue, ZodSchema } from 'zod';
/**
* Formats Zod issues into an object mapping dot-notation paths to issue arrays.
* Useful for structuring errors for TanStack Form (may need adjustment based on version).
*/
const prefixSchemaToErrors = (issues: readonly ZodIssue[]) => {
const schema = new Map<string, ZodIssue[]>();
for (const issue of issues) {
const path = [...issue.path]
.map(segment => (typeof segment === 'number' ? `[${segment}]` : segment))
.join('.')
.replace(/\.\[/g, '[');
schema.set(path, (schema.get(path) ?? []).concat(issue));
}
return Object.fromEntries(schema);
};
/**
* Core async validator logic. Parses the value against the schema.
* Returns Zod issues for field validation failure or a specific
* error object structure for form validation failure.
*/
const checkSchemaOnValidatorAsync = async <TData>({
schema,
value,
validationSource = 'field'
}: {
schema: ZodSchema<TData>;
value: TData;
validationSource?: 'field' | 'form';
}) => {
const validation = await schema.safeParseAsync(value);
if (!validation.success) {
// For field-level, return raw issues
if (validationSource === 'field') {
return validation.error.issues;
}
// For form-level, return the structured errors object
const schemaErrors = prefixSchemaToErrors(validation.error.issues);
return {
form: schemaErrors,
field: schemaErrors
};
}
// Return undefined on success
return undefined;
};
/**
* Factory function to create an async validator suitable for TanStack Form FIELDS
* (e.g., field-level onChangeAsync, onBlurAsync, onSubmitAsync).
*/
export const checkSchemaOnFieldValidatorAsync =
<TData>(schema: ZodSchema<TData>) =>
// Note: TanStack field validators often receive { value, signal, fieldApi }.
// This simplified version only uses value. Adapt if signal/fieldApi are needed.
({ value }: { value: TData }) =>
checkSchemaOnValidatorAsync({ schema, value, validationSource: 'field' });
/**
* Factory function to create an async validator suitable for TanStack Form's
* form-level async validators.
*/
export const checkSchemaOnFormValidatorAsync =
<TFormData>(schema: ZodSchema<TFormData>): FormValidateAsyncFn<TFormData> =>
({ value }) => // Assumes FormValidateAsyncFn provides { value }
checkSchemaOnValidatorAsync({ schema, value, validationSource: 'form' });
2. Usage Examples:
Here’s how you can use these factories:
A. Field-Level Async Validation (e.g., onSubmitAsync on a field)
Instead of passing the Zod schema directly to the field's validator prop, pass the result of the factory function.
<form.AppField
name='username' // The name of the field in your form data
validators={{
// Use the factory for async validation on the field
onSubmitAsync: checkSchemaOnFieldValidatorAsync(checkUserSchema)
}}
children={field => (
// Your field rendering logic...
)}
/>
B. Form-Level onSubmitAsync Validation
This directly addresses the original issue scenario. Use the checkSchemaOnFormValidatorAsync factory for the form's onSubmitAsync validator.
const form = useAppForm({
defaultValues: {
username: '',
note: ''
},
validators: {
// Use the factory for the form-level onSubmitAsync
onSubmitAsync: checkSchemaOnFormValidatorAsync(annotatedUsersSchema) // <<--- Usage
},
onSubmit: ({ value, formApi }) => {
// This runs only if sync and async validations pass
...
});
I am experiencing this issue too. I am using createServerValidate from @tanstack/react-form/nextjs with an async zod refine.
It appears to happen on the first call of the function returned from createServerValidate and then it works as expected on subsequent calls.
A note about the workaround:
You can use the
parseValuesWithSchemaAsyncmethod to get formatted errors more easily. See tanstack.com/form/latest/docs/reference/classes/fieldapi#parsevaluewithschemaasync for referenceonSubmitAsync: async ({ value, formApi }) => { // <--- Workaround implementation console.log('Manually triggering safeParseAsync'); // This validation triggers the network request via checkUser return await formApi.parseValuesWithSchemaAsync(asyncIgnoredUsersSchemaRefine); } Edit
This appears to also be an issue of
onBlurAsyncandonChangeAsync. Test case below:it('should only run onSubmitAsync schemas once on submit', async () => { vi.useFakeTimers() const asyncCallMock = vi.fn()
const schema = z.object({ name: z.string().refine(async () => { asyncCallMock() return true }, 'Some error message'), }) const form = new FormApi({ defaultValues: { name: '', }, validators: { onSubmitAsync: schema, }, }) form.mount() form.handleSubmit() await vi.runAllTimersAsync() expect(asyncCallMock).toHaveBeenCalledOnce()})
I tried using parseValuesWithSchemaAsync and it still called the async schema 2 times.
Turns out this is a known issue with Zod standard schema validation: https://github.com/colinhacks/zod/issues/5137
@luchillo17 I see. I suppose I'll leave this one open for now in case it's relevant to the linked discussion. Once that's resolved, we can close it here.
Doubt it will win enough traction, only the OP and I are asking for a fix, granted this issue itself only has about 4 people...
@Aleksnako The only issue with the workaround is that it doesn't account for transformed data, I have transforms happening so types had conflicts, I ended up spending a whole 2 1/2 days on this solution based on yours:
import { z } from 'zod';
import type {
StandardSchemaV1,
StandardSchemaV1Issue,
TStandardSchemaValidatorIssue,
TStandardSchemaValidatorValue,
ValidationSource,
} from '@tanstack/react-form';
/**
* Formats Zod issues into an object mapping dot-notation paths to issue arrays.
* Useful for structuring errors for TanStack Form (may need adjustment based on version).
*/
function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) {
const schema = new Map<string, StandardSchemaV1Issue[]>();
for (const issue of issues) {
const path = [...(issue.path ?? [])]
.map((segment) => {
const normalizedSegment =
typeof segment === 'object' ? segment.key : segment;
return typeof normalizedSegment === 'number'
? `[${normalizedSegment}]`
: normalizedSegment;
})
.join('.')
.replace(/\.\[/g, '[');
schema.set(path, (schema.get(path) ?? []).concat(issue));
}
return Object.fromEntries(schema);
}
const transformFormIssues = <TSource extends ValidationSource>(
issues: readonly StandardSchemaV1Issue[],
): TStandardSchemaValidatorIssue<TSource> => {
const schemaErrors = prefixSchemaToErrors(issues);
return {
form: schemaErrors,
fields: schemaErrors,
} as TStandardSchemaValidatorIssue<TSource>;
};
/**
* Core async validator logic. Parses the value against the schema.
* Returns Zod issues for field validation failure or a specific
* error object structure for form validation failure.
*/
const validateAsync = async <
TSource extends ValidationSource,
TDataInput,
TDataOutput = TDataInput,
>(
{ value, validationSource }: TStandardSchemaValidatorValue<unknown, TSource>,
schema: z.ZodType<TDataOutput, TDataInput>,
): Promise<TStandardSchemaValidatorIssue<TSource> | undefined> => {
const result = await schema.safeParseAsync(value);
if (result.success) return;
if (validationSource === 'field') {
return result.error
?.issues as unknown as TStandardSchemaValidatorIssue<TSource>;
}
return transformFormIssues<TSource>(result.error?.issues);
};
/**
* Factory function to create an async validator suitable for TanStack Form FIELDS
* (e.g., field-level onChangeAsync, onBlurAsync, onSubmitAsync).
*/
export const checkSchemaOnFieldValidatorAsync =
<TData>(schema: z.ZodSchema<TData>) =>
// Note: TanStack field validators often receive { value, signal, fieldApi }.
// This simplified version only uses value. Adapt if signal/fieldApi are needed.
({ value }: { value: TData }) =>
validateAsync({ value, validationSource: 'field' }, schema);
/**
* Factory function to create an async validator suitable for TanStack Form's
* form-level async validators.
*/
export const checkSchemaOnFormValidatorAsync =
<TFormDataInput, TFormDataOutput = TFormDataInput>(
schema: z.ZodType<TFormDataOutput, TFormDataInput>,
) =>
(
{ value }: { value: TFormDataInput }, // Assumes FormValidateAsyncFn provides { value }
) =>
validateAsync({ value, validationSource: 'form' }, schema);
I just couldn't get around the as unknown from Zod v4 issues to TStandardSchemaValidatorIssue<TSource>.
Perhaps I can help with the typing. I'll take a look tomorrow.
Perhaps I can help with the typing. I'll take a look tomorrow.
@LeCarbonator I think the issue is in Zod v4 the discriminated union of the zod issues is very broad, so it doesn't match TStandardSchemaValidatorIssue<TSource>, for now it works, but it still leaves a bad taste to use as unknown.
This is what the z.core.$ZodIssue[] computes to:
Long discriminated type
type issues = ({
readonly code: "invalid_type";
readonly expected: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | "optional" | "date" | "map" | "success" | "null" | "any" | "literal" | ... 25 more;
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "string" | "number" | "bigint" | "date" | "array" | "int" | "file" | "set" | { ... 52 more };
readonly maximum: number | bigint;
readonly inclusive?: boolean;
readonly exact?: boolean;
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "too_small";
readonly origin: "string" | "number" | "bigint" | "date" | "array" | "int" | "file" | "set" | { ... 52 more };
readonly minimum: number | bigint;
readonly inclusive?: boolean;
readonly exact?: boolean;
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "invalid_format";
readonly format: "date" | "includes" | "email" | "duration" | "url" | "time" | { ... 52 more } | "datetime" | "emoji" | "uuid" | "guid" | "nanoid" | "cuid" | "cuid2" | "ulid" | ... 16 more;
readonly pattern?: string;
readonly input?: string;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "not_multiple_of";
readonly divisor: number;
readonly input?: number | bigint;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "unrecognized_keys";
readonly keys: string[];
readonly input?: Record<string, unknown>;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "invalid_union";
readonly errors: ...[];
readonly input: unknown;
readonly discriminator?: string;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "invalid_key";
readonly origin: "map" | "record";
readonly issues: ...;
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "invalid_element";
readonly origin: "map" | "set";
readonly key: unknown;
readonly issues: ...;
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "invalid_value";
readonly values: (string | number | bigint | symbol | boolean | null | undefined)[];
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
} | {
readonly code: "custom";
readonly params?: Record<string, any>;
readonly input: unknown;
readonly path: (string | number | symbol)[];
readonly message: string;
})[]
(property) $ZodError<T = unknown>.issues: z.core.$ZodIssue[]
@luchillo17 You don't really need to rely on the TanStack types so heavily. Zod issues extend standard schema validation, so this snippet below should do just fine. Additionally, unlike the standard schema validator implementation in form-core, you don't need to rely on generics as you are using factory functions in your case.
Here's my take on it:
import type { z } from 'zod'
type AsyncZodFieldValidator<TData> = (arg: {
value: TData
}) => Promise<z.ZodIssue[] | undefined>
type AsyncZodFormValidator<TData> = (arg: { value: TData }) => Promise<
| {
form: Record<string, z.ZodIssue[]>
fields: Record<string, z.ZodIssue[]>
}
| undefined
>
/**
* Formats Zod issues into an object mapping dot-notation paths to issue arrays.
* Useful for structuring errors for TanStack Form (may need adjustment based on version).
*/
function prefixSchemaToErrors(issues: readonly z.ZodIssue[]) {
const schema = new Map<string, z.ZodIssue[]>()
for (const issue of issues) {
const path = issue.path
.map((segment) => {
return typeof segment === 'number' ? `[${segment}]` : segment
})
.join('.')
.replace(/\.\[/g, '[')
schema.set(path, (schema.get(path) ?? []).concat(issue))
}
return Object.fromEntries(schema)
}
/**
* Factory function to create an async validator suitable for TanStack Form FIELDS
* (e.g., field-level onChangeAsync, onBlurAsync, onSubmitAsync).
*/
export const checkSchemaOnFieldValidatorAsync = <TInput, TOutput = TInput>(
schema: z.ZodSchema<TOutput, z.ZodTypeDef, TInput>,
): AsyncZodFieldValidator<TInput> => {
return async ({ value }) => {
const result = await schema.safeParseAsync(value)
if (result.success) return
return result.error.issues
}
}
/**
* Factory function to create an async validator suitable for TanStack Form's
* form-level async validators.
*/
export const checkSchemaOnFormValidatorAsync = <TInput, TOutput = TInput>(
schema: z.ZodType<TOutput, z.ZodTypeDef, TInput>,
): AsyncZodFormValidator<TInput> => {
return async ({ value }) => {
const result = await schema.safeParseAsync(value)
if (result.success) return
const schemaErrors = prefixSchemaToErrors(result.error.issues)
return {
form: schemaErrors,
fields: schemaErrors,
}
}
}
Usage
import { z } from 'zod'
import { useForm } from '@tanstack/react-form'
import {
checkSchemaOnFieldValidatorAsync,
checkSchemaOnFormValidatorAsync,
} from './custom-validators'
const schema = checkSchemaOnFormValidatorAsync(
z.object({
foo: z.string(),
}),
)
const fieldSchema = checkSchemaOnFieldValidatorAsync(z.string())
function App() {
const app = useForm({
defaultValues: { foo: '' },
validators: {
onChangeAsync: schema,
},
})
return (
<app.Field
name="foo"
validators={{
onChangeAsync: fieldSchema,
}}
>
{(field) =>
field.state.meta.errorMap.onChange?.map((v) => v.message).join(',')
}
</app.Field>
)
}
@LeCarbonator Thanks, could you check on a couple of things?
I did mine like that (basically copying the original code for prefixSchemaToErrors & transformFormIssues because these are not exported by @tanstack/form, I made it like this to keep the Types compatible with the original ones computed when you pass a Zod Schema directly to the form, the error types look like this with a Zod schema, with my version, and with yours (in that order):
Photos
Not that yours is wrong, it still satisfies the general shape expected of the errors.
Also that's Zod v3 code, z.ZodIssue is deprecated and z.ZodType changed, tried replacing with v4 equivalents z.core.$ZodIssue & the new shape, this is yours in Zod v4 version, and using StandardSchemaV1Issue to output the same type as the library:
import type { z } from 'zod';
import type { StandardSchemaV1Issue } from '@tanstack/react-form';
type AsyncZodFieldValidator<TData> = (arg: {
value: TData;
}) => Promise<StandardSchemaV1Issue[] | undefined>;
type AsyncZodFormValidator<TData> = (arg: { value: TData }) => Promise<
| {
form: Record<string, StandardSchemaV1Issue[]>;
fields: Record<string, StandardSchemaV1Issue[]>;
}
| undefined
>;
/**
* Formats Zod issues into an object mapping dot-notation paths to issue arrays.
* Useful for structuring errors for TanStack Form (may need adjustment based on version).
*/
function prefixSchemaToErrors(
issues: readonly z.core.$ZodIssue[],
): Record<string, StandardSchemaV1Issue[]> {
const schema = new Map<string, z.core.$ZodIssue[]>();
for (const issue of issues) {
const path = issue.path
.map((segment) => {
return typeof segment === 'number' ? `[${segment}]` : segment;
})
.join('.')
.replace(/\.\[/g, '[');
schema.set(path, (schema.get(path) ?? []).concat(issue));
}
return Object.fromEntries(schema);
}
/**
* Factory function to create an async validator suitable for TanStack Form FIELDS
* (e.g., field-level onChangeAsync, onBlurAsync, onSubmitAsync).
*/
export const checkSchemaOnFieldValidatorAsync = <TInput, TOutput = TInput>(
schema: z.ZodType<TOutput, TInput>,
): AsyncZodFieldValidator<TInput> => {
return async ({ value }) => {
const result = await schema.safeParseAsync(value);
if (result.success) return;
return result.error.issues;
};
};
/**
* Factory function to create an async validator suitable for TanStack Form's
* form-level async validators.
*/
export const checkSchemaOnFormValidatorAsync = <TInput, TOutput = TInput>(
schema: z.ZodType<TOutput, TInput>,
): AsyncZodFormValidator<TInput> => {
return async ({ value }) => {
const result = await schema.safeParseAsync(value);
if (result.success) return;
const schemaErrors = prefixSchemaToErrors(result.error.issues);
return {
form: schemaErrors,
fields: schemaErrors,
};
};
};
I don't have any attachment to Standard Schema but if they fix this in the future I want the types to match for minimal removal of this code in the future.
https://zod.dev/v4/changelog?id=updates-generics
I don't use TanStack/form, but commenting here as the reporter on that zod issue https://github.com/colinhacks/zod/issues/5137.
In my case I just didn't want a double network request happening, so I just made the async check a singleton.
export const createSingletonService = () => {
const PROMISES = new Map<string, Promise<unknown>>();
return {
run: async <T>(key: string, cb: () => Promise<T>): Promise<T> => {
if (PROMISES.has(key)) {
const data = await PROMISES.get(key);
return data as T;
}
const promise = cb();
PROMISES.set(key, promise);
const data = await promise;
return data;
},
};
};
const singletonService = createSingletonService();
let count = 0;
const test1 = z
.object({
email: z.string(),
})
.check(async ({ issues, value }) => {
const validation = await singletonService.run("cache_key", async () => {
count++;
await delay(2000);
return {
valid: false,
};
});
console.log(count); // count = 1
if (!validation.valid) {
issues.push({
code: "custom",
input: "...",
});
}
console.log(issues);
});
Might help some of you here