Skip to content

feat(v4): add parseMaybeAsync / safeParseMaybeAsync (#5379)#5924

Closed
dokson wants to merge 1 commit intocolinhacks:mainfrom
dokson:feat/parse-maybe-async
Closed

feat(v4): add parseMaybeAsync / safeParseMaybeAsync (#5379)#5924
dokson wants to merge 1 commit intocolinhacks:mainfrom
dokson:feat/parse-maybe-async

Conversation

@dokson
Copy link
Copy Markdown
Contributor

@dokson dokson commented May 1, 2026

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

parse is sync-only and throws $ZodAsyncError the moment any validator returns a Promise. parseAsync always 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) returns MaybeAsync<ParsePayload>, and util.MaybeAsync<T> = T | Promise<T> is already defined. The only thing missing is a public method that exposes the raw shape.

The internal ~standard.validate integration already needs this behaviour and currently fakes it with a try/catch double-parse:

validate: (value: unknown) => {
  try {
    const r = safeParse(inst, value);
    return r.success ? { value: r.data } : { issues: r.error?.issues };
  } catch (_) {
    // re-parse everything, this time async, just to get past $ZodAsyncError
    return safeParseAsync(inst, value).then((r) => );
  }
}

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 _parseMaybeAsync and _safeParseMaybeAsync factories in core/parse.ts that run with ctx.async = true (so async validators don't throw $ZodAsyncError) and return:

  • the raw payload synchronously if schema._zod.run returned a non-Promise,
  • a Promise that awaits and finalises if it returned a Promise.

Wire them through:

  • ZodType.parseMaybeAsync / safeParseMaybeAsync (classic)
  • ZodMiniType.parseMaybeAsync / safeParseMaybeAsync (mini)
  • parseMaybeAsync / safeParseMaybeAsync re-exports in classic/parse.ts and mini/parse.ts

Then refactor ~standard.validate onto safeParseMaybeAsync — the try/catch + double-parse becomes a single call:

validate: (value: unknown) => {
  const r = safeParseMaybeAsync(inst, value);
  if (r instanceof Promise) {
    return r.then((rr) => (rr.success ? { value: rr.data } : { issues: rr.error?.issues }));
  }
  return r.success ? { value: r.data } : { issues: r.error?.issues };
}

No behaviour change for callers of the StandardSchema interface — they were already getting a Promise | object and 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:

  • parseMaybeAsync sync return when no async work
  • parseMaybeAsync Promise return on async refinement (success)
  • parseMaybeAsync sync throw on validation failure
  • parseMaybeAsync async reject with ZodError on async failure
  • safeParseMaybeAsync sync success / failure
  • safeParseMaybeAsync async success / failure
  • ~standard.validate stays sync for fully-sync schemas
  • ~standard.validate resolves async for schemas with async refinements

packages/zod/src/v4/mini/tests/parse-maybe-async.test.ts:

  • ZodMiniType.parseMaybeAsync sync / async paths
  • ZodMiniType.safeParseMaybeAsync sync success / failure

Local results

Test Files  340 passed (341)   [redos checker is the unrelated Windows flake — fixed by #5919]
     Tests  3812 passed (3813)
Type Errors no errors

pnpm test was run with --no-verify on 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

  • 4 new methods on ZodType / ZodMiniType, 4 new top-level functions exposed via core/parse.ts. Strictly additive — every existing call site keeps its current typing and behaviour.
  • The ~standard.validate refactor is internal and observably equivalent.

Note about CI

Test with TypeScript latest will likely show red because of the repo-wide baseUrl deprecation surfacing under TS 6 since e58ea4d; #5921 fixes that. Once it lands, this PR's CI noise should clear.

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 1, 2026

Reviewed PR #5924 — approved with no issues. Implementation correctly adds parseMaybeAsync/safeParseMaybeAsync that return sync values synchronously and Promises only when async work is involved. The Standard Schema validate() refactor from try/catch-then-reparse to a single safeParseMaybeAsync call is a clean improvement. All 24 tests pass (runtime + typecheck).

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

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.

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

Pullfrog  | View workflow run | Using Claude Opus𝕏

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 1, 2026

TL;DR — Adds parseMaybeAsync and safeParseMaybeAsync to Zod v4, enabling sync-when-possible parsing that only pays the Promise cost when the schema actually encounters async work. Also refactors the internal ~standard.validate implementation from a try/catch double-parse into a single safeParseMaybeAsync call.

Key changes

  • Add _parseMaybeAsync / _safeParseMaybeAsync core factories — run with ctx.async = true and return T | Promise<T> depending on whether the underlying schema._zod.run produced a Promise.
  • Wire instance methods on ZodType and ZodMiniType — both classic and mini schemas gain .parseMaybeAsync() and .safeParseMaybeAsync() methods.
  • Refactor ~standard.validate onto safeParseMaybeAsync — eliminates the try/catch + re-parse fallback, making the sync hot path a single parse call.
  • Add test coverage for classic and mini — verifies sync return for sync schemas, Promise return for async refinements, error handling in both modes, and StandardSchema integration.

Summary | 8 files | 1 commit | base: mainfeat/parse-maybe-async


Core parse factories

Before: only parse (sync-only, throws on async) and parseAsync (always returns Promise) existed.
After: _parseMaybeAsync and _safeParseMaybeAsync run with async: true context and return the result synchronously when schema._zod.run returns a non-Promise, or wrap in a Promise when it does.

The implementation runs the schema exactly once — the finalize callback is applied either directly or via .then() depending on the return type of run.

core/parse.ts · classic/parse.ts · mini/parse.ts


StandardSchema validate refactor

Before: ~standard.validate called safeParse in a try/catch, falling back to safeParseAsync on $ZodAsyncError—parsing the input twice for async schemas.
After: a single safeParseMaybeAsync call handles both paths without re-parsing.

The observable behavior is unchanged — callers of the StandardSchema interface still receive object | Promise<object>. The difference is purely internal: async schemas no longer trigger a redundant sync parse that always throws.

core/schemas.ts


Instance methods on ZodType / ZodMiniType

Before: schemas only exposed parse, parseAsync, safeParse, safeParseAsync.
After: parseMaybeAsync and safeParseMaybeAsync are available as instance methods on both classic and mini schema types.

classic/schemas.ts · mini/schemas.ts


Tests

Before: no coverage for conditional async parsing behavior.
After: dedicated test files cover sync/async return paths, error propagation, and StandardSchema integration for both classic and mini.

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

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

Copy link
Copy Markdown

@oxc oxc left a comment

Choose a reason for hiding this comment

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

Nice 👍🏻

)

`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
@colinhacks
Copy link
Copy Markdown
Owner

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.

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.

Add parseMaybeAsync method for conditional async parsing

3 participants