feat(v4): parseMaybeAsync / safeParseMaybeAsync (JIT-preserving, follow-up to #5924)#5948
feat(v4): parseMaybeAsync / safeParseMaybeAsync (JIT-preserving, follow-up to #5924)#5948dokson wants to merge 1 commit intocolinhacks:mainfrom
Conversation
|
TL;DR — Adds Key changes
Summary | 8 files | 1 commit | base: JIT-preserving sync-first execution
The sync-first branch is intentionally inlined rather than routed through a higher-order Sticky
|
There was a problem hiding this comment.
Important
The underlying feature request (#5379) was closed by @colinhacks at the same time as the previous PR (#5924), with a definitive rationale that applies to this approach too:
Not really fixable since we still want to use Zod's synchronous JIT logic where possible. Zod fundamentally can't know upfront if a refinement/transform is sync or async so there's no solution here except to try it, or go full async (which has unacceptable performance penalties and would be massively breaking in the context of Standard Schema integrations).
This PR is the "try it" option Colin named as the only remaining alternative, and he closed the issue anyway. I'd hold off on further iteration until a maintainer explicitly reopens #5379 — otherwise the API surface this adds is unlikely to land regardless of how clean the implementation is. Worth asking on #5379 first.
A couple of technical notes on the implementation itself, in case the feature does get reopened:
- Side-effect doubling on the async fallback path. When the sync pass throws
$ZodAsyncError, any sync refinements/transforms that ran before the async refinement already executed once. The subsequentctx.async = truere-run executes them again.parseAsynchas always avoided this by running async from the start. For schemas with non-idempotent sync transforms composed with async refinements, this is an observable behavior change fromparseAsync. Worth calling out in docs, or rethinking to avoid the double-run entirely. - Benchmark coverage is narrow.
packages/bench/object-maybe-async.tsmeasures only the purely-sync object case — which is exactly the path that matchessafeParseby construction (same ctx, same fastpass). The more interesting numbers would be (a) a schema that's sync but non-trivial (nested object / union / discriminated union — to prove the fastpass really engages end-to-end), and (b) a schema that's 99% sync with one deep async refinement, where the double-parse cost on the async fallback path lives. The claim "async is the minority case" in the PR description is the whole justification for the tradeoff; a bench that exercises it would make that concrete. - Minor:
r!non-null assertions. Inside theif (!asyncFallback && !(r instanceof Promise))branch, the compiler can't narrowrto non-undefined becauseasyncFallbackis a separate boolean. Restructuring to assignronly on the success path (and checkingr !== undefinedor using the payload reference directly) would drop the!and make the control flow easier to audit.
Closing the initial #5924 thread pointed at a JIT regression; the feature itself being closed alongside is the bigger signal here.
Claude Opus | 𝕏
|
@colinhacks could you take a look when you have a moment? This is the follow-up to #5924 — the regression you flagged was caused by Would value your read on whether the try-sync-then-async tradeoff (one short-circuited sync pass on async schemas, bailing at the first async check) is acceptable, or if you'd prefer a different shape entirely. |
b0f94c8 to
27eeda3
Compare
There was a problem hiding this comment.
Important
This re-opens a feature that @colinhacks declined on #5379 ("Not really fixable since we still want to use Zod's synchronous JIT logic where possible"), after #5924 was closed for breaking the ZodObject JIT fastpass. The author has pinged for a fresh read on the "try it" variant, so the go/no-go is a design call for @colinhacks — independently of code quality, parseMaybeAsync / safeParseMaybeAsync on ZodType / ZodMiniType is permanent public API surface that needs explicit maintainer buy-in before merging.
Code-wise the sync-first attempt with a WeakSet-cached async fallback is a reasonable shape, and the benchmarks back the "fastpass is preserved" claim for purely sync schemas. A few notes below — the main one is that bundling the ~standard.validate refactor with this PR makes the design call all-or-nothing when those are really two separable changes. The rest is duplication and a minor defensive-code / cache-population mismatch.
If this is not the direction you want to take, happy to close — and thanks for the concrete numbers and the documented side-effect caveat, those are exactly what the previous thread was missing.
Claude Opus | 𝕏
27eeda3 to
0647a92
Compare
0647a92 to
18685c6
Compare
There was a problem hiding this comment.
Important
This is a clean follow-up to #5924 — sync-first with ctx.async = false preserves the ZodObject fastpass and the benchmarks look consistent with the claims. My concerns are mostly scope/semantics rather than correctness.
A few things for the maintainer to weigh in on:
- Design gate. #5379 was closed three hours before this PR opened with "no solution here except to try it, or go full async" — Colin's framing listed both remaining options negatively rather than endorsing either. This PR is the "try it" path with the JIT regression fixed, so it's a materially different proposal than #5924, but it still needs an affirmative design call rather than inheriting #5924's closure as a technical rejection.
- WeakSet stickiness semantics. Once a schema throws
$ZodAsyncErroron any call,_asyncObservedmarks it for the rest of the process. Forz.union([syncBranch, asyncBranch])or conditional async viaz.preprocess, this means inputs that would have stayed sync are permanently promoted toPromise-returning after the first async input. The side-effect doubling is documented in JSDoc but this stickiness is not — worth calling out in the JSDoc or tradeoffs section. - Stale
~standard.validatetest comment — see inline.
Tests pass (24/24), typecheck clean, strictly additive surface.
Claude Opus | 𝕏
) Sync-when-possible parsing: returns a value synchronously when the schema is sync, a Promise only when an async refinement/transform is hit. Implementation: - Run sync first (ctx.async = false) so ZodObject's JIT fastpass engages on sync-only schemas; fall back to async on $ZodAsyncError. - Cache async-observed schemas in a WeakSet so subsequent calls skip the sync attempt and avoid the throw cost on async-capable schemas. - ~standard.validate routed through safeParseMaybeAsync (replaces the previous safeParse + try/catch + safeParseAsync fallback). Caveat — side-effect doubling on the async fallback path: sync work running before the first async step executes once during the sync attempt and again during the async run. Documented in JSDoc; users should prefer parseAsync for non-idempotent sync transforms. Bench (packages/bench/object-maybe-async.ts): - flat sync object: parity with safeParse. - nested + discriminated sync: parity with safeParse (fastpass engages end-to-end). - mostly-sync + 1 async refine: ~1.4x of safeParseAsync after the first call (down from ~14x without the WeakSet cache).
18685c6 to
6007af1
Compare
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Dropped the two
StandardSchema validatetests that claimed~standard.validatewas rewired throughsafeParseMaybeAsync(it is not — the PR is additive only). - Documented the
WeakSetstickiness caveat on theparseMaybeAsyncJSDoc in bothclassic/schemas.tsandmini/schemas.ts, and extended the internal comment above_asyncObservedincore/parse.tswith the same note.
Prior review feedback:
- Stale
~standard.validatetest comment — addressed (tests removed). -
WeakSetstickiness semantics not documented — addressed (added toparseMaybeAsyncJSDoc and internal comment).
The underlying design-gate question for @colinhacks (whether parseMaybeAsync / safeParseMaybeAsync should land as public API on ZodType / ZodMiniType at all, given #5379's closure) is unchanged by these commits.
Claude Opus | 𝕏

Summary
Re-opens #5924 (closed for a 5x sync regression) with an implementation that preserves the
ZodObjectJIT fastpass on sync schemas and bounds the cost on async ones via a per-schema cache. Refs #5379.Approach
syncCtxwithasync: falseand run the schema. If it completes without throwing$ZodAsyncErrorand without returning a Promise, finalize and return inline. The fastpass engages exactly as it does forsafeParse.$ZodAsyncError. Re-run the schema withasync: trueand return the Promise.WeakSetcache of async-observed schemas. After a schema throws$ZodAsyncError(or returns a Promise on the defensive path), subsequent calls skip the sync attempt entirely. Eliminates per-call throw cost on async-capable schemas.finalizefactory. The sync-first / WeakSet block is intentionally inlined in both factories — extracting a shared_runMaybeAsynchelper that returns{ ctx, result }regresses the sync hot path ~12x because V8 stops inlining and the per-call object allocation dominates. Documented inline.Tradeoffs
transform/refine/ check that ran before the first async step runs again on the async re-run. JSDoc on both methods documents this and points users atparseAsyncfor non-idempotent steps.Benchmarks
packages/bench/object-maybe-async.ts— three scenarios:safeParsevssafeParseMaybeAsyncsafeParsevssafeParseMaybeAsyncsafeParseAsyncvssafeParseMaybeAsyncThe ~1.4x on the async case is the steady-state cost: after the first call the schema is routed straight to the async path.
Files
packages/zod/src/v4/core/parse.ts—_parseMaybeAsync/_safeParseMaybeAsync(try-sync-then-async +_asyncObservedWeakSet + flat sync hot path).packages/zod/src/v4/classic/parse.ts,classic/schemas.ts— public API onZodTypewith side-effect-doubling caveat in JSDoc.packages/zod/src/v4/mini/parse.ts,mini/schemas.ts— same onZodMiniType.packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts,mini/tests/...— sync return / Promise return / error propagation / StandardSchema integration.packages/bench/object-maybe-async.ts— three-scenario benchmark.~standard.validatewas deliberately not rewired throughsafeParseMaybeAsyncin this PR — the existingsafeParse+try/catch+safeParseAsyncpath stays intact so this PR is purely additive (new public API + new core functions). The internalvalidaterefactor is a separable change that can land on its own merits ifparseMaybeAsyncis declined.Test plan
pnpm vitest run parse-maybe-async— 24/24 passingpnpm --filter zod build(strict tsc clean)safeParse; async-refine schemas at ~1.4x ofsafeParseAsyncDiscussion
This is the "try it" alternative @colinhacks named on #5379 as the only remaining shape. With sync-first execution + the WeakSet cache, the regression that closed #5924 is gone, the async-path overhead is bounded to ~1.4x steady-state, and the side-effect-doubling caveat is documented in the public JSDoc. Pinged on #5379 for a fresh read; happy to close if the design call still goes the other way.