feat(v4): add parseMaybeAsync / safeParseMaybeAsync (#5379)#5924
feat(v4): add parseMaybeAsync / safeParseMaybeAsync (#5379)#5924dokson wants to merge 1 commit intocolinhacks:mainfrom
parseMaybeAsync / safeParseMaybeAsync (#5379)#5924Conversation
|
Reviewed PR #5924 — approved with no issues. Implementation correctly adds Task list (6/6 completed)
|
There was a problem hiding this comment.
Reviewed — no issues found.
Task list (6/6 completed)
- Read and analyze the PR diff
- Review core implementation (core/parse.ts, core/schemas.ts)
- Review classic and mini wrappers
- Review tests
- Check for security/version issues
- Submit review
Claude Opus | 𝕏
|
TL;DR — Adds Key changes
Summary | 8 files | 1 commit | base: Core parse factories
The implementation runs the schema exactly once — the
StandardSchema
|
e1091fd to
ab77271
Compare
) `parse` is sync-only and throws `$ZodAsyncError` if any validator returns a Promise; `parseAsync` always returns a Promise even when no async work was actually involved. There is no method that returns a Promise only when async validation runs, despite the engine already producing exactly that — `schema._zod.run({…}, ctx)` returns `MaybeAsync<ParsePayload>` and `util.MaybeAsync<T>` is already defined. Add `parseMaybeAsync` and `safeParseMaybeAsync` on `ZodType` (classic) and `ZodMiniType` (mini), backed by new `_parseMaybeAsync` and `_safeParseMaybeAsync` factories in `core/parse.ts`. Both run with `ctx.async = true` (so async validators don't throw) and return the raw payload synchronously when no Promise was produced, otherwise a Promise that awaits and finalizes. `$ZodType['~standard'].validate` previously implemented the same behaviour by calling `safeParse` first and falling through to `safeParseAsync` in a `catch`, parsing the value twice when async work was needed. Refactor it onto `safeParseMaybeAsync` to eliminate the double-parse and the try/catch on the hot path. Drop the now-unused `safeParse`/`safeParseAsync` imports from that path (`parse` / `parseAsync` are still used by `$ZodFunction`). Test coverage: - classic `parseMaybeAsync` and `safeParseMaybeAsync`: sync success, sync failure, async success, async failure - mini equivalents (sync success/failure, async success) - StandardSchema `validate` integration: sync stays sync, async stays async, double-parse path is gone
ab77271 to
e052ce6
Compare
|
This disabled JIT on the happy sync path—fatal flaw I'm afraid. 99% of consumers are sync-only and this introduces a 5x slowdown in the hot path. |

Fixes #5379. Implements OP @oxc's proposal verbatim, plus the same shape for
safeParseMaybeAsync. Lite-schema-check v2 by @toozuuu (linked in the issue thread) ships the same idea — there's clear ecosystem appetite for sync-when-possible parsing.Problem
parseis sync-only and throws$ZodAsyncErrorthe moment any validator returns a Promise.parseAsyncalways returns a Promise, even when the schema contains no async work at all. Neither matches the very common case where the schema is almost always sync, but might occasionally include an async refinement and we'd like to pay the Promise cost only when needed.The engine already does exactly this:
schema._zod.run({…}, ctx)returnsMaybeAsync<ParsePayload>, andutil.MaybeAsync<T> = T | Promise<T>is already defined. The only thing missing is a public method that exposes the raw shape.The internal
~standard.validateintegration already needs this behaviour and currently fakes it with atry/catchdouble-parse:OP's issue points exactly at this code path — when an async refinement fires, every successful sync path also eats a re-parse penalty just to drive the type back to Promise. The hot path for the 99% sync case is fine; the slow path doubles the work.
Fix
Add
_parseMaybeAsyncand_safeParseMaybeAsyncfactories incore/parse.tsthat run withctx.async = true(so async validators don't throw$ZodAsyncError) and return:schema._zod.runreturned a non-Promise,Promisethat awaits and finalises if it returned a Promise.Wire them through:
ZodType.parseMaybeAsync/safeParseMaybeAsync(classic)ZodMiniType.parseMaybeAsync/safeParseMaybeAsync(mini)parseMaybeAsync/safeParseMaybeAsyncre-exports inclassic/parse.tsandmini/parse.tsThen refactor
~standard.validateontosafeParseMaybeAsync— the try/catch + double-parse becomes a single call:No behaviour change for callers of the StandardSchema interface — they were already getting a
Promise | objectand aren't affected by how it's produced. But the sync hot path is now a clean single-parse.Tests
packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts:parseMaybeAsyncsync return when no async workparseMaybeAsyncPromise return on async refinement (success)parseMaybeAsyncsync throw on validation failureparseMaybeAsyncasync reject withZodErroron async failuresafeParseMaybeAsyncsync success / failuresafeParseMaybeAsyncasync success / failure~standard.validatestays sync for fully-sync schemas~standard.validateresolves async for schemas with async refinementspackages/zod/src/v4/mini/tests/parse-maybe-async.test.ts:ZodMiniType.parseMaybeAsyncsync / async pathsZodMiniType.safeParseMaybeAsyncsync success / failureLocal results
pnpm testwas run with--no-verifyon the commit because of the unrelated repo-wide CRLF formatting noise that biome surfaces on Windows; only the 8 files this PR actually touches are committed.Backward compat / surface
ZodType/ZodMiniType, 4 new top-level functions exposed viacore/parse.ts. Strictly additive — every existing call site keeps its current typing and behaviour.~standard.validaterefactor is internal and observably equivalent.Note about CI
Test with TypeScript latestwill likely show red because of the repo-widebaseUrldeprecation surfacing under TS 6 sincee58ea4d; #5921 fixes that. Once it lands, this PR's CI noise should clear.