Repository navigation
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 | 𝕏
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 | 𝕏
6007af1 to
e4dc3d0
Compare
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. - Instance methods on classic only. zod/mini gets the tree-shakeable top-level functions, so bundles that don't use them pay nothing. 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).
e4dc3d0 to
fed2426
Compare
- Sync attempt calls `_parse`/`_safeParse` directly, so the hot path is the same JIT-ed function as `safeParse` (parity on sync objects; was ~1.3x slower). - Async fallback calls `_parseAsync`/`_safeParseAsync` instead of `ar.then(closure)`; the per-call closure cost ~1µs. Now at or below `safeParseAsync`. - Replace the module-level WeakSet with a per-instance `_zod.asyncSeen` flag: cheaper lookup, no global state. - Tests for per-instance caching, sticky union, side-effect count, and sync propagation of user throws.
|
Pushed a perf follow-up (393876e) after another profiling pass:
|
393876e to
0c06c31
Compare

Summary
Re-opens #5924 (closed for a 5x sync regression) with an implementation that keeps the
ZodObjectJIT fastpass on sync schemas and matchessafeParseAsyncon async ones. Refs #5379.Approach
parseMaybeAsync/safeParseMaybeAsynccall_parse/_safeParsedirectly, so the hot path is the same JIT-compiled function assafeParse(ctx.async === false, fastpass engaged).$ZodAsyncError, via the existing async parser. The schema is re-run through_parseAsync/_safeParseAsync. Any other error propagates synchronously, as withparse._zod.asyncSeenis set and later calls skip the sync attempt. No module-level state; clones start fresh.Tradeoffs
parseAsyncfor non-idempotent steps).z.union([sync, async])), later calls on it return a Promise, even for inputs that would resolve synchronously.Benchmarks
packages/bench/object-maybe-async.ts, plus interleaved micro-benchmarks (median of rounds):safeParsesafeParsesafeParseAsyncFiles
packages/zod/src/v4/core/parse.ts:_parseMaybeAsync/_safeParseMaybeAsync.packages/zod/src/v4/core/schemas.ts: internalasyncSeenflag on$ZodTypeInternals.packages/zod/src/v4/classic/parse.ts,classic/schemas.ts: public API onZodType+ JSDoc.packages/zod/src/v4/mini/parse.ts: tree-shakeable top-level functions (no methods on mini).packages/zod/src/v4/{classic,mini}/tests/parse-maybe-async.test.ts: sync/async returns, error propagation, per-instance caching, sticky union, side-effect count.packages/bench/object-maybe-async.ts: benchmark.~standard.validateis deliberately not rewired in this PR, so the change stays purely additive. That path could reuse the same flag to stop re-running the sync prefix on known-async schemas; that's a separate follow-up.Test plan
vitest run parse-maybe-async: 30/30 passing. Each new test fails against a deliberately broken implementation (cache removed, all errors swallowed, sync attempt skipped).pnpm --filter zod build(strict tsc clean)