Repository navigation
feat: validate zod schemas on the synchronous path when they allow it #7626
Description
Describe the feature you'd like to request
getParseFn binds parseAsync as soon as a validator exposes it, before it considers parse:
// packages/server/src/unstable-core-do-not-import/parser.ts
if (typeof parser.parseAsync === 'function') {
// ParserZodEsque
return parser.parseAsync.bind(parser);
}So every zod schema given to .input() or .output() validates on zod's async path, even in an app with no async refinement or transform anywhere. On zod 4's interpreter that path costs about twice the sync one. It also cannot reach the compiled parser added in zod 4.5 (zod/compile), because the compiled run hands any call with an async context back to the interpreter:
// zod/v4/core/compile.js
if (ctx?.async || ctx?.direction === "backward" || ctx?.skipChecks || ctx?.[FALLBACK_FLAG]) {
return originalRun(payload, ctx);
}Measured on tRPC 11.18.0, zod 4.5.4 and Node 24.21.0, with a page of nine-key objects, in µs per call, median of seven rounds. parseAsync is what getParseFn binds today. ~standard is tRPC's own Standard Schema branch, copied as is with its async wrapper. parse is a plain synchronous call.
Interpreter:
| items | parseAsync |
~standard |
parse |
|---|---|---|---|
| 1 | 6.6 | 3.1 | 3.3 |
| 10 | 42.8 | 20.1 | 23.3 |
| 100 | 338 | 162 | 160 |
| 1000 | 3 545 | 1 793 | 1 662 |
With import 'zod/compile':
| items | parseAsync |
~standard |
parse |
|---|---|---|---|
| 1 | 6.3 | 0.88 | 0.79 |
| 10 | 49.7 | 5.3 | 5.0 |
| 100 | 401 | 41 | 43 |
| 1000 | 3 932 | 335 | 361 |
Today a procedure pays 1.8 to 2.1 times the sync cost on the interpreter, and 8 to 11 times once compilation is on. Turning compilation on even makes procedures slower, by 11 to 19 % from ten items up, because the compiled wrapper sits on every schema node and always defers. The gap between zod's two paths is known upstream and was closed as not planned (colinhacks/zod#3446).
We hit this on a NestJS app with 167 procedures and no async refinement anywhere.
Describe the solution you'd like to see
Try parse first in the ParserZodEsque branch, and switch to parseAsync for good when a schema refuses to run synchronously:
if (typeof parser.parseAsync === 'function') {
// ParserZodEsque
if (typeof parser.parse !== 'function') {
return parser.parseAsync.bind(parser);
}
let isAsync = false;
return (value) => {
if (!isAsync) {
try {
return parser.parse(value);
} catch (cause) {
// A validation failure carries `issues`. Anything else means the
// schema needs the async path (async refinement or transform).
if (Array.isArray((cause as { issues?: unknown } | null)?.issues)) {
throw cause;
}
isAsync = true;
}
}
return parser.parseAsync(value);
};
}What it keeps:
error.causeis still aZodError, so the formatter in the error formatting docs (error.cause instanceof ZodError) keeps working.- Async refinements and transforms, which are why
parseAsyncwas adopted in Support Zod's "parseAsync" method #1131 and Allow async input validators + support zod'sparseAsync#1173, go through the fallback. I checked an async.refine()on zod 4 throughcreateCaller: valid input passes, invalid input is rejected withBAD_REQUESTand aZodErrorcause. ParseFnalready allows a synchronous return, and both validation middlewaresawaitit.getParseFnruns once per.input()or.output()call, so the flag lives with the procedure and costs nothing per request.
The cost lands on schemas that do need async. Their synchronous callbacks run twice on the first call of each procedure (once in the aborted sync attempt, once in parseAsync), and only parseAsync runs after that. A refinement that throws a non-validation error also moves its procedure to the async path, and the caller still gets the error from that run.
zod uses the same approach in its own Standard Schema validate: safeParse, then safeParseAsync in the catch (zod/v4/core/schemas.js).
Describe alternate solutions
- Check
~standardbeforeparseAsync. Type inference already prefers Standard Schema since fix: prefer Standard Schema for input/output type inference #6888, zod 4's~standard.validateis sync first, and the tables show this branch running as fast asparse. The diff would be smaller, buterror.causebecomes aStandardSchemaV1Errorinstead of aZodError, which breaks the documented formatter. It may suit a major version better. - Put the proposal above behind an option on
initTRPC.create(), if you want the default behaviour left untouched. - In userland,
.input((v) => schema.parse(v))takes theParserCustomValidatorEsquebranch. It works, but it has to be repeated on every procedure, and a function parser takes its input type from its return type, so a schema with a transform loses its input type. Adapters that read the schema statically cannot use it at all: nestjs-trpc requires a bare schema in its decorators. I proposed the change there first (perf: procedure validation always runs on the validator's async path KevinEdry/nestjs-trpc#120, perf: add opt-in synchronous parsing for procedure inputs and outputs KevinEdry/nestjs-trpc#121). The maintainer would rather not change the parse path in an adapter and pointed atgetParseFn.
Additional information
Benchmark script
Run once as is and once with compile as argument. Each payload holds distinct objects: reusing one object reference across the array skews the compiled numbers.
const compile = process.argv[2] === 'compile';
if (compile) await import('zod/compile');
const { z } = await import('zod');
const Item = z.object({
id: z.string().uuid(), name: z.string().min(1).max(120), email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']), score: z.number().min(0).max(100),
active: z.boolean(), tags: z.array(z.string()).optional(),
createdAt: z.union([z.date(), z.string()]), updatedAt: z.union([z.date(), z.string()]),
});
const Page = z.object({ items: z.array(Item), total: z.number().int(), hasMore: z.boolean() });
const item = {
id: '3f2504e0-4f89-11d3-9a0c-0305e82c3301', name: 'Ada', email: 'ada@example.com',
role: 'admin', score: 42, active: true, tags: ['a', 'b'],
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
};
const fns = {
parseAsync: Page.parseAsync.bind(Page), // what getParseFn binds today
standard: async (v) => { // getParseFn's Standard Schema branch
const r = await Page['~standard'].validate(v);
if (r.issues) throw new Error('issues');
return r.value;
},
parse: Page.parse.bind(Page),
};
const med = (a) => [...a].sort((x, y) => x - y)[Math.floor(a.length / 2)];
for (const n of [1, 10, 100, 1000]) {
const pool = Array.from({ length: 4 }, () => ({
items: Array.from({ length: n }, (_, i) => ({ ...item, name: `Ada ${i}`, tags: [...item.tags] })),
total: n,
hasMore: false,
}));
const iters = Math.max(30, Math.round(20000 / n));
const r = Object.fromEntries(Object.keys(fns).map((k) => [k, []]));
for (let k = 0; k < 7; k++) {
for (const [name, f] of Object.entries(fns)) {
for (let i = 0; i < iters; i++) await f(pool[i % 4]);
const t = process.hrtime.bigint();
for (let i = 0; i < iters; i++) await f(pool[i % 4]);
r[name].push(Number(process.hrtime.bigint() - t) / iters / 1000);
}
}
console.log(n, Object.entries(r).map(([k, v]) => `${k} ${med(v).toFixed(2)} µs`).join(' '));
}👨👧👦 Contributing
- 🙋♂️ Yes, I'd be down to file a PR implementing this feature!
🤖 Generated with Claude Code
Activity
perf(server): try the sync parser first for zod-like schemas