Skip to content

feat(v4): parseMaybeAsync / safeParseMaybeAsync (JIT-preserving, follow-up to #5924) - #5948

Open
dokson wants to merge 2 commits into
colinhacks:mainfrom
dokson:feat/parse-maybe-async-jit-preserving
Open

dokson wants to merge 2 commits into
colinhacks:mainfrom
dokson:feat/parse-maybe-async-jit-preserving

Conversation

@dokson

@dokson dokson commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Re-opens #5924 (closed for a 5x sync regression) with an implementation that keeps the ZodObject JIT fastpass on sync schemas and matches safeParseAsync on async ones. Refs #5379.

Approach

  1. Sync first, via the existing sync parser. parseMaybeAsync / safeParseMaybeAsync call _parse / _safeParse directly, so the hot path is the same JIT-compiled function as safeParse (ctx.async === false, fastpass engaged).
  2. Async fallback on $ZodAsyncError, via the existing async parser. The schema is re-run through _parseAsync / _safeParseAsync. Any other error propagates synchronously, as with parse.
  3. Per-instance cache. Once a schema goes async, _zod.asyncSeen is set and later calls skip the sync attempt. No module-level state; clones start fresh.

Tradeoffs

  • Side-effect doubling on the first async parse. Sync checks/transforms before the first async step run once in the sync attempt and again in the async run. This happens only on that first call per instance; it's documented in the JSDoc (prefer parseAsync for non-idempotent steps).
  • Sticky per instance. After a schema has gone async (e.g. one branch of 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):

Scenario Comparison Result
Flat sync object (3 fields) vs safeParse parity (40.6 vs 39.6 ns/parse)
Nested object + array + discriminated union (sync) vs safeParse parity
Mostly-sync schema with 1 async refine vs safeParseAsync parity to ~10% faster

Files

  • packages/zod/src/v4/core/parse.ts: _parseMaybeAsync / _safeParseMaybeAsync.
  • packages/zod/src/v4/core/schemas.ts: internal asyncSeen flag on $ZodTypeInternals.
  • packages/zod/src/v4/classic/parse.ts, classic/schemas.ts: public API on ZodType + 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.validate is 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)
  • Benchmarks above

@pullfrog

pullfrog Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR — Adds parseMaybeAsync / safeParseMaybeAsync (instance methods + core functions) that return sync when the schema is sync and a Promise only when an async refinement is hit. Reopens #5924 with an implementation that preserves the ZodObject JIT fastpass and adds a WeakSet cache for async-observed schemas, bringing the async-path overhead down from ~14.5x to ~1.4x vs safeParseAsync.

Key changes

  • New parseMaybeAsync / safeParseMaybeAsync API — exposed on both ZodType (classic) and ZodMiniType (mini), typed as util.MaybeAsync<T>, with a JSDoc caveat covering side-effect doubling and sticky async observation.
  • Sync-first execution — try ctx.async = false first so the ZodObject fastpass stays engaged; fall back to an async run on $ZodAsyncError or a returned Promise.
  • WeakSet cache for async-observed schemas — after a schema throws $ZodAsyncError once, subsequent calls skip the sync attempt entirely and route straight to the async path. Sticky per schema instance.
  • Purely additive — ~standard.validate is deliberately left on its existing safeParse + try/catch → safeParseAsync path; the internal rewire is deferred to a separable PR.
  • Three-scenario micro-benchmark — packages/bench/object-maybe-async.ts covers flat sync, nested sync, and mostly-sync-with-one-async-refine.

Summary | 8 files | 1 commit | base: main ← feat/parse-maybe-async-jit-preserving


JIT-preserving sync-first execution

Before: #5924 set ctx.async = true unconditionally, which disabled the ZodObject fastpass (jit && fastEnabled && ctx?.async === false && ctx.jitless !== true) and regressed sync object parsing by ~5x.
After: _parseMaybeAsync / _safeParseMaybeAsync build a syncCtx with async: false, run the schema, and return inline when it completes without throwing $ZodAsyncError and without returning a Promise.

The sync-first branch is intentionally inlined rather than routed through a higher-order finalize factory — the PR notes that extracting a shared { ctx, result } helper regresses the sync path ~12x (per-call alloc + lost V8 inlining).

core/parse.ts


Sticky WeakSet cache for async-observed schemas

Before: A schema with an async refinement paid the cost of a short-circuited sync prefix plus the async run on every call (~14.5x vs safeParseAsync in the bench).
After: The first $ZodAsyncError adds the schema to a module-level WeakSet; subsequent calls skip the sync attempt and go straight to the async path (~1.4x steady-state, bounded by the WeakSet lookup and wrapper).

What are the tradeoffs? Two caveats, both now called out in JSDoc on the public methods:
  1. Side-effect doubling on first async observation. Sync work that runs before the first async step executes twice (once in the aborted sync attempt, once in the async re-run). For schemas with non-idempotent sync transforms composed with an async refinement this is observable. The JSDoc points users at parseAsync for those cases.
  2. Sticky async routing. Once a schema is in the WeakSet, every subsequent call returns a Promise — even for inputs that would have resolved synchronously (e.g. the sync branch of z.union([sync, async]), or a preprocess-gated async branch). Fully avoiding the double-walk or the stickiness would require changing the $ZodAsyncError convention and is out of scope.

core/parse.ts · object-maybe-async.ts


Public API surface on ZodType and ZodMiniType

Before: Callers that didn't know up front whether a schema was async had to call safeParseAsync defensively and always await, forcing a microtask even for fully sync schemas.
After: schema.parseMaybeAsync(data) and schema.safeParseMaybeAsync(data) return core.output<T> / SafeParseResult<...> directly for sync schemas and a Promise of the same when async work is needed.

The instance wiring mirrors the existing parse / parseAsync pairs in both entry points, and the mini parse.ts re-export list picks up the two new names alongside parseAsync / safeParseAsync. Test coverage in parse-maybe-async.test.ts for both classic and mini asserts sync return, Promise return, and error propagation for sync and async paths.

classic/schemas.ts · mini/schemas.ts · classic/parse.ts · classic/tests/parse-maybe-async.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 subsequent ctx.async = true re-run executes them again. parseAsync has 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 from parseAsync. Worth calling out in docs, or rethinking to avoid the double-run entirely.
  • Benchmark coverage is narrow. packages/bench/object-maybe-async.ts measures only the purely-sync object case — which is exactly the path that matches safeParse by 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 the if (!asyncFallback && !(r instanceof Promise)) branch, the compiler can't narrow r to non-undefined because asyncFallback is a separate boolean. Restructuring to assign r only on the success path (and checking r !== undefined or 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.

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

@dokson

dokson commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

@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 ctx.async = true upfront bypassing the ZodObject JIT fastpass (gated on ctx?.async === false strict). Running sync first and falling back to async only on $ZodAsyncError keeps the fastpass engaged; benchmarks now show parity with safeParse on sync object schemas.

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.

@dokson
dokson marked this pull request as draft May 4, 2026 17:38
@dokson
dokson force-pushed the feat/parse-maybe-async-jit-preserving branch from b0f94c8 to 27eeda3 Compare May 4, 2026 17:44
@dokson
dokson marked this pull request as ready for review May 4, 2026 17:45

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/core/schemas.ts Resolved
Comment thread packages/zod/src/v4/core/parse.ts Resolved
Comment thread packages/zod/src/v4/core/parse.ts Resolved
Comment thread packages/zod/src/v4/core/parse.ts Resolved
Comment thread packages/zod/src/v4/classic/schemas.ts Resolved
@dokson
dokson force-pushed the feat/parse-maybe-async-jit-preserving branch from 27eeda3 to 0647a92 Compare May 4, 2026 17:48
@dokson
dokson marked this pull request as draft May 4, 2026 17:50
@dokson
dokson force-pushed the feat/parse-maybe-async-jit-preserving branch from 0647a92 to 18685c6 Compare May 4, 2026 17:56
@dokson
dokson marked this pull request as ready for review May 4, 2026 17:57

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $ZodAsyncError on any call, _asyncObserved marks it for the rest of the process. For z.union([syncBranch, asyncBranch]) or conditional async via z.preprocess, this means inputs that would have stayed sync are permanently promoted to Promise-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.validate test comment — see inline.

Tests pass (24/24), typecheck clean, strictly additive surface.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/core/parse.ts Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Dropped the two StandardSchema validate tests that claimed ~standard.validate was rewired through safeParseMaybeAsync (it is not — the PR is additive only).
  • Documented the WeakSet stickiness caveat on the parseMaybeAsync JSDoc in both classic/schemas.ts and mini/schemas.ts, and extended the internal comment above _asyncObserved in core/parse.ts with the same note.

Prior review feedback:

  • Stale ~standard.validate test comment — addressed (tests removed).
  • WeakSet stickiness semantics not documented — addressed (added to parseMaybeAsync JSDoc 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.

Pullfrog  | Fix it ➔ | View workflow run | Using 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.
- 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).
- 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.
@dokson

dokson commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a perf follow-up (393876e) after another profiling pass:

  • Sync path: delegates straight to _parse / _safeParse, so it runs the same JIT-compiled function as safeParse. On sync objects it's now on par (40.6 vs 39.6 ns per parse on a flat object); before this commit it was ~1.3x slower.
  • Async path: delegates to _parseAsync / _safeParseAsync instead of ar.then(closure). The per-call closure cost about 1µs. On the mostly-sync + 1 async refine bench it's now ~10% faster than safeParseAsync, where it used to be ~1.25x slower.
  • Cache: the module-level WeakSet is gone; a per-instance _zod.asyncSeen flag replaces it. No global state, and clones start fresh.
  • Tests: added coverage for per-instance caching, the sticky union case, the side-effect count, and sync propagation of user throws. Each new test catches at least one deliberately broken version of the implementation.

@colinhacks
colinhacks force-pushed the feat/parse-maybe-async-jit-preserving branch from 393876e to 0c06c31 Compare October 12, 2026 14:30

This branch was successfully deployed

1 active deployment
Preview – zod-v4 — 0c06c316 Deployed Oct 12, 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.

1 participant