Skip to content

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

Open
dokson wants to merge 1 commit intocolinhacks:mainfrom
dokson:feat/parse-maybe-async-jit-preserving
Open

feat(v4): parseMaybeAsync / safeParseMaybeAsync (JIT-preserving, follow-up to #5924)#5948
dokson wants to merge 1 commit intocolinhacks:mainfrom
dokson:feat/parse-maybe-async-jit-preserving

Conversation

@dokson
Copy link
Copy Markdown
Contributor

@dokson dokson commented May 4, 2026

Summary

Re-opens #5924 (closed for a 5x sync regression) with an implementation that preserves the ZodObject JIT fastpass on sync schemas and bounds the cost on async ones via a per-schema cache. Refs #5379.

Approach

  1. Sync first. Build syncCtx with async: false and run the schema. If it completes without throwing $ZodAsyncError and without returning a Promise, finalize and return inline. The fastpass engages exactly as it does for safeParse.
  2. Async fallback on $ZodAsyncError. Re-run the schema with async: true and return the Promise.
  3. WeakSet cache 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.
  4. Hot path is flattened. No higher-order finalize factory. The sync-first / WeakSet block is intentionally inlined in both factories — extracting a shared _runMaybeAsync helper that returns { ctx, result } regresses the sync hot path ~12x because V8 stops inlining and the per-call object allocation dominates. Documented inline.

Tradeoffs

  • Side-effect doubling on the async fallback. When the sync attempt throws, any sync 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 at parseAsync for non-idempotent steps.
  • WeakSet is module-level global state. Keys are schema instances (GC-eligible). Consumers that construct fresh schemas per request never benefit from the cache and pay the try-sync-first cost on every call. Long-lived schemas (the common case) amortize the throw to a single first call.

Benchmarks

packages/bench/object-maybe-async.ts — three scenarios:

Scenario Comparison Ratio
Flat sync object (3 fields) safeParse vs safeParseMaybeAsync ~1.0–1.3x (within noise)
Nested object + array + discriminated union (sync) safeParse vs safeParseMaybeAsync ~1.0–1.2x (sometimes maybe-async wins)
Mostly-sync schema with 1 async refine safeParseAsync vs safeParseMaybeAsync ~1.4x (down from ~14.5x without the WeakSet cache)

The ~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 + _asyncObserved WeakSet + flat sync hot path).
  • packages/zod/src/v4/classic/parse.ts, classic/schemas.ts — public API on ZodType with side-effect-doubling caveat in JSDoc.
  • packages/zod/src/v4/mini/parse.ts, mini/schemas.ts — same on ZodMiniType.
  • 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.validate was deliberately not rewired through safeParseMaybeAsync in this PR — the existing safeParse + try/catch + safeParseAsync path stays intact so this PR is purely additive (new public API + new core functions). The internal validate refactor is a separable change that can land on its own merits if parseMaybeAsync is declined.

Test plan

  • pnpm vitest run parse-maybe-async — 24/24 passing
  • pnpm --filter zod build (strict tsc clean)
  • Bench: sync schemas at parity with safeParse; async-refine schemas at ~1.4x of safeParseAsync

Discussion

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.

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 4, 2026

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/catchsafeParseAsync path; the internal rewire is deferred to a separable PR.
  • Three-scenario micro-benchmarkpackages/bench/object-maybe-async.ts covers flat sync, nested sync, and mostly-sync-with-one-async-refine.

Summary | 8 files | 1 commit | base: mainfeat/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𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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
Copy link
Copy Markdown
Contributor Author

dokson commented May 4, 2026

@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
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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 Outdated
Comment thread packages/zod/src/v4/core/parse.ts
Comment thread packages/zod/src/v4/core/parse.ts Outdated
Comment thread packages/zod/src/v4/core/parse.ts
Comment thread packages/zod/src/v4/classic/schemas.ts Outdated
@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
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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/classic/tests/parse-maybe-async.test.ts Outdated
Comment thread packages/zod/src/v4/core/parse.ts Outdated
)

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).
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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𝕏

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