Skip to content

feat: validate zod schemas on the synchronous path when they allow it #7626

Open

Description

@dlecan

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.cause is still a ZodError, so the formatter in the error formatting docs (error.cause instanceof ZodError) keeps working.
  • Async refinements and transforms, which are why parseAsync was adopted in Support Zod's "parseAsync" method #1131 and Allow async input validators + support zod's parseAsync  #1173, go through the fallback. I checked an async .refine() on zod 4 through createCaller: valid input passes, invalid input is rejected with BAD_REQUEST and a ZodError cause.
  • ParseFn already allows a synchronous return, and both validation middlewares await it.
  • getParseFn runs 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

  1. Check ~standard before parseAsync. Type inference already prefers Standard Schema since fix: prefer Standard Schema for input/output type inference #6888, zod 4's ~standard.validate is sync first, and the tables show this branch running as fast as parse. The diff would be smaller, but error.cause becomes a StandardSchemaV1Error instead of a ZodError, which breaks the documented formatter. It may suit a major version better.
  2. Put the proposal above behind an option on initTRPC.create(), if you want the default behaviour left untouched.
  3. In userland, .input((v) => schema.parse(v)) takes the ParserCustomValidatorEsque branch. 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 at getParseFn.

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

  1. added a commit that references this issue on Oct 19, 2026
    432edfa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions