Skip to content

perf: add opt-in synchronous parsing for procedure inputs and outputs - #121

Closed
dlecan wants to merge 1 commit into
KevinEdry:mainfrom
dlecan:perf/sync-first-validation
Closed

dlecan wants to merge 1 commit into
KevinEdry:mainfrom
dlecan:perf/sync-first-validation

Conversation

@dlecan

@dlecan dlecan commented Sep 8, 2026

Copy link
Copy Markdown

Closes #120.

getParseFn binds parseAsync as soon as a validator exposes it, so today every
procedure validates on the validator's async path even when no schema in the application
has an async refinement. On zod that costs ×2 to ×3.7 depending on payload size, and it
makes zod 4.5's compiled fast path unreachable, since
zod/compile defers to the interpreter on any async context. Numbers and references are in the issue.

What this adds

syncValidation on TRPCModuleOptions, off by default. When enabled, ProcedureFactory
wraps input and output schemas in a parser that tries the synchronous path first. tRPC
accepts a plain function as a first-class parser (ParserCustomValidatorEsque), so the
rest of the pipeline is untouched, and codegen is unaffected because the generator reads
the decorators statically rather than the runtime value.

  • lib/utils/sync-first-parser.util.ts: the parser.
  • lib/interfaces/module-options.interface.ts: the option, documented like its neighbours.
  • lib/factories/procedure.factory.ts: injects TRPC_MODULE_OPTIONS the way
    MiddlewareFactory already does, and resolves the parser at the two wiring points.

Design notes

The parser assumes as little as possible about the validator, since the peer range covers
zod 3 and zod 4:

  • It engages only for a schema exposing both parse and parseAsync; anything else is
    returned untouched, including plain function parsers and standard-schema-only objects.
  • An exception carrying issues is a validation verdict and propagates unchanged. Anything
    else means the validator refused to run synchronously, and the call switches to
    parseAsync.
  • The switch is remembered per schema. Without that, a schema with an async refinement
    would run its synchronous callbacks twice on every single call; with it, that happens
    once per process. zod applies the same bound inside its own compiler.

Tests

lib/utils/__tests__/sync-first-parser.util.spec.ts covers the pass-through, the
synchronous path with parseAsync spied to prove it is never touched, the validation error
propagating without an async retry, the fallback for an async refinement, the single
aborted attempt across three calls, and a transforming schema keeping its parsed output.

lib/factories/__tests__/procedure.factory.spec.ts gains a syncValidation block
asserting that schemas reach tRPC untouched when the option is off, that they are wrapped
when it is on, and that the wrapped parser validates like the schema it replaces. The
existing suite required one change: ProcedureFactory now injects TRPC_MODULE_OPTIONS,
so the testing module provides it.

Full package suite: 18 files, 87 tests, green. Lint and tsc --project tsconfig.build.json
clean.

If you would rather have it on by default

Say so and I will flip it. The opt-in exists only to keep the change free of behavioural
risk for existing users; the fallback makes the default-on version correct too, at the cost
of that one duplicate run per async schema.

tRPC's getParseFn binds parseAsync as soon as a validator exposes it, so every procedure
validates on the validator's async path even when no schema in the application has an
async refinement. On zod that costs twice to nearly four times the synchronous path
depending on payload size, and it puts the compiled fast path of zod 4.5 out of reach,
since zod/compile defers to the interpreter on any async context.

The new `syncValidation` option, off by default, wraps input and output schemas in a
parser that tries the synchronous path first. tRPC accepts a plain function as a
first-class parser, so the rest of the pipeline is untouched, and codegen is unaffected
because the generator reads the decorators statically rather than the runtime value.

The parser assumes as little as possible about the validator, since the peer range covers
zod 3 and zod 4: it engages only for a schema exposing both `parse` and `parseAsync`,
treats an exception carrying `issues` as a validation verdict and propagates it unchanged,
and switches to `parseAsync` on anything else. That switch is remembered per schema, so a
schema with an async refinement pays the aborted synchronous attempt once per process
rather than running its synchronous callbacks twice on every call.
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

@dlecan is attempting to deploy a commit to the Kevin's Projects Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nestjs-trpc-docs Ready Ready Preview Sep 8, 2026 9:01pm UTC

Request Review

@KevinEdry

KevinEdry commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Thanks for this — the investigation in #120 is one of the best-measured perf reports this repo has gotten, and the implementation here is careful. I'm going to decline the approach, though, and I want to propose a direction that keeps the win.

The library's contract is to wire tRPC into NestJS, with tRPC behaving exactly as it does when wired by hand. A module option that changes which parser path runs breaks that contract, once it's in, this library owns validation-scheduling behavior and its interactions with every future tRPC and zod release.
The ordering problem you found lives in tRPC's getParseFn, and the default-path fix belongs there. Your benchmarks would carry a lot of weight in an upstream issue.

That said, you've surfaced a real gap on our side. A raw tRPC user hits this and writes .input((v) => schema.parse(v)) — a parser tRPC supports natively. A nestjs-trpc user can't, because the decorators require a bare, statically-analyzable schema for type generation.
So we're currently less capable than native tRPC here, and that's ours to fix.

The fix I'd take a PR for: support function parsers in @Query/@Mutation input/output, matching tRPC's ParserCustomValidatorEsque and its type semantics (input type = return type, same as tRPC infers for any function parser).
Runtime is close to free — ProcedureFactory already passes the value through to .input().
The work is in the generator: copy the parser expression into the generated file and resolve its imports, failing generation with a clear error on expressions it can't resolve.
With that in place, your use case becomes a three-line syncParse(schema) helper applied in your decorators, no module option, and it keeps working unchanged if tRPC fixes the ordering upstream.

Happy to scope that in an issue if you'd rather not rework this PR. And if you do open the upstream issue, link it here 🙏

@dlecan

dlecan commented Oct 2, 2026

Copy link
Copy Markdown
Author

Opened upstream as trpc/trpc#7626, with the numbers redone on zod 4.5. I'll come back to the function parser proposal separately.

@dlecan

dlecan commented Oct 2, 2026

Copy link
Copy Markdown
Author

Thanks for pointing in this direction. Checking 2.13 first, function parsers in the decorators already generate and import correctly when the helper is exported, so #123 only covers what was missing: a clear error for an unexported parser function, export function declared in a router file, a stable import order, and the docs. Happy to close this PR now, or once #123 lands, whichever you prefer.

@KevinEdry

Copy link
Copy Markdown
Owner

Closing in favor of #123 and trpc/trpc#7626 — thanks for carrying this in both directions. I'll review #123 shortly.

@KevinEdry KevinEdry closed this Oct 7, 2026

This branch was successfully deployed

1 active deployment
Preview — 80aa98cf Deployed Sep 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: procedure validation always runs on the validator's async path

2 participants