3. ParsePayload, Hybrid Sync/Async Execution & Checks Engine
At runtime, Zod v4.6.7 validates, transforms, and encodes data through a mutable payload state machine (ParsePayload<T>) orchestrated by packages/zod/src/v4/core/parse.ts, packages/zod/src/v4/core/schemas.ts, and packages/zod/src/v4/core/checks.ts. Rather than allocating new context objects on every nested property or wrapping synchronous values in Promise.resolve(), Zod v4 executes a hybrid synchronous/asynchronous pipeline that stays 100% synchronous unless an async refinement or transform returns a Promise, supports bidirectional forward (decode) and backward (encode) execution for codecs and pipes, and defers ZodError construction via a lazy getter on safeParse failures.
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.lines import line
from drawlib.smartarts import CardList, ChevronProcess, Panel
from drawlib.styles import Styles
from drawlib.text import text
setup(width=176, height=112)
text(
(88, 107.5),
"ParsePayload State Machine & Hybrid Sync/Async Execution Engine",
style=Styles.DarkBold.patch(text_size=13.5),
)
text(
(88, 103.5),
"How parse.ts and schemas.ts route ParsePayload across forward/backward directions, async handoffs, and lazy error getters",
style=Styles.Muted.patch(text_size=10.0),
)
# ==============================================================================
# TOP PIPELINE: 5-Stage Parse Execution Lifecycle (x=5..171, y=85..98)
# ==============================================================================
pipe = ChevronProcess(
style=Styles.PrimaryNeutral,
text_style=Styles.DarkBold.patch(text_size=9.5),
description_style=Styles.Dark.patch(text_size=9.5),
corner_angle=60.0,
spacing=1.2,
flat_left_end=True,
)
pipe.add(
"1. Runner Entrypoint",
description="_parse / _safeParse / _encode",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
pipe.add(
"2. ParsePayload Init",
description="{ value, issues: [] } + ctx",
)
pipe.add(
"3. _zod.run Dispatch",
description="forward vs backward canary",
)
pipe.add(
"4. Sync / Async Check",
description="instanceof Promise handoff",
)
pipe.add(
"5. Finalize / Lazy Error",
description="{ success } or throw Error",
style=Styles.AccentFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
pipe.draw(xy=(5, 85), width=166, height=13)
# ==============================================================================
# BOTTOM LEFT: 1. Bidirectional _zod.run Execution (x=5..86, y=5..78)
# ==============================================================================
p_run = Panel(
"1. Bidirectional _zod.run Pipeline (schemas.ts:L218-291)",
style=Styles.PrimaryNeutral,
header_style=Styles.Primary,
text_style=Styles.WhiteBold.patch(text_size=10.5),
icon=phosphor.arrows_left_right,
icon_style=Styles.White,
header_height=6.0,
)
p_run.draw(xy=(5, 5), width=81, height=73)
run_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=9.5),
description_style=Styles.Muted.patch(text_size=9.5),
badge_shape="none",
direction="vertical",
spacing=1.5,
)
run_cards.add(
"Zero-Check Fast Path (checks.length === 0)",
description="Deferred hook sets inst._zod.run = inst._zod.parse directly",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
run_cards.add(
"Forward Mode (direction: 'forward' — parse / decode)",
description="1) _zod.parse(payload, ctx) --> 2) runChecks(result, checks)",
style=Styles.PrimaryNeutral,
)
run_cards.add(
"Backward Mode Step 1: Canary Pass (direction: 'backward')",
description="Runs _zod.parse({ value, issues: [] }, { skipChecks: true })",
)
run_cards.add(
"Backward Mode Step 2: Reverse Checks Before Parse",
description="Runs runChecks(payload, checks) FIRST, then _zod.parse(res, ctx)",
)
run_cards.add(
"Check Loop & Continuable vs Non-Continuable Issues",
description="Sequential check loop; stops if explicitlyAborted() is true",
)
run_cards.draw(xy=(7.5, 7.5), width=76, height=62.5)
# ==============================================================================
# BOTTOM RIGHT: 2. Abort Semantics, Async Handoff & Lazy Errors (x=90..171, y=5..78)
# ==============================================================================
p_state = Panel(
"2. Abort Flags, Async Handoff & Lazy failure() Getter",
style=Styles.Neutral,
header_style=Styles.Secondary,
text_style=Styles.WhiteBold.patch(text_size=10.5),
icon=phosphor.lightning,
icon_style=Styles.White,
header_height=6.0,
)
p_state.draw(xy=(90, 5), width=81, height=73)
state_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=9.5),
description_style=Styles.Muted.patch(text_size=9.5),
badge_shape="none",
direction="vertical",
spacing=1.5,
)
state_cards.add(
"ParsePayload<T> & ParseContextInternal (schemas.ts)",
description="Payload: { value, issues, aborted? }; Ctx: { async, direction }",
style=Styles.PrimaryNeutral,
)
state_cards.add(
"util.aborted(x, start) vs util.explicitlyAborted(x, start)",
description="aborted: continue !== true; explicitlyAborted: continue === false",
)
state_cards.add(
"Hybrid Sync -> Async Promise Escalation",
description="Zero Promises allocated until a parser or check returns a Promise",
style=Styles.AccentFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
state_cards.add(
"Sync Guard: Throws $ZodAsyncError When ctx.async === false",
description="Calling schema.parse() on an async check throws $ZodAsyncError",
)
state_cards.add(
"Lazy failure(Err, issues, ctx) Getter (parse.ts:L83-103)",
description="safeParse builds ZodError only when .error getter is accessed!",
style=Styles.PrimaryNeutral,
)
state_cards.draw(xy=(92.5, 7.5), width=76, height=62.5)
# Arrows from pipeline to bottom panels
line((45, 85), (45, 78), arrow_head="->", style=Styles.PrimaryBold)
line((130, 85), (130, 78), arrow_head="->", style=Styles.SecondaryBold)
save()
1. Execution Entrypoints (packages/zod/src/v4/core/parse.ts)
All schema validation calls—whether invoked as schema.parse(data) in v4/classic, z.parse(schema, data) in v4/mini, or schema["~standard"].validate(data) via Standard Schema—funnel into the higher-order runner factories in packages/zod/src/v4/core/parse.ts.
1.1 The 10 Core Runner Factories in parse.ts
Runner Factory (v4/core/parse.ts) |
Direction | Async Mode | Return Value on Failure | Notes |
|---|---|---|---|---|
_parse(Err) / parse |
"forward" |
async: false |
throw new Err(finalIssues) |
Throws $ZodAsyncError if _zod.run returns a Promise. |
_parseAsync(Err) / parseAsync |
"forward" |
async: true |
Rejects with new Err(finalIssues) |
Wraps sync result in Promise.resolve if no async check ran. |
_safeParse(Err) / safeParse |
"forward" |
async: false |
failure(Err, issues, ctx) |
Uses lazy get error() getter on { success: false }. |
_safeParseAsync(Err) / safeParseAsync |
"forward" |
async: true |
Resolves to failure(Err, issues, ctx) |
Async counterpart to _safeParse. |
_encode(Err) / encode |
"backward" |
async: false |
throw new Err(finalIssues) |
Reverses $ZodPipe and $ZodCodec (out -> in). |
_decode(Err) / decode |
"forward" |
async: false |
throw new Err(finalIssues) |
Explicit forward decode entrypoint for codecs. |
_encodeAsync / _decodeAsync |
"backward" / "forward" |
async: true |
Rejects with new Err(finalIssues) |
Async codec encode/decode runners. |
_safeEncode / _safeDecode (+ Async) |
"backward" / "forward" |
false / true |
failure(Err, issues, ctx) |
Safe result-object counterparts for codecs. |
1.2 Lazy Error Finalization in failure() (parse.ts:L83-103)
In many high-throughput applications, callers invoke const res = schema.safeParse(input) and branch on if (!res.success) return 400; without ever inspecting res.error. Constructing a full ZodError requires mapping every raw issue through util.finalizeIssue(iss, ctx, config()), resolving locale error maps, and formatting error strings.
To make ignored safeParse failures near-free, parse.ts returns a lazy getter from failure():
// packages/zod/src/v4/core/parse.ts:L83-103
function failure(
_Err: errors.$ZodErrorClass,
issues: errors.$ZodRawIssue[],
ctx: schemas.ParseContextInternal
): util.SafeParseError<any> {
let error: errors.$ZodError | undefined;
let _issues: errors.$ZodRawIssue[] | undefined = issues;
let _ctx: schemas.ParseContextInternal | undefined = ctx;
return {
success: false,
get error() {
if (!error) {
const finalIssues = _issues!.map((iss) => util.finalizeIssue(iss, _ctx!, config()));
error = new _Err(finalIssues);
// Release references so the closure does not retain parsed input values in memory
_issues = undefined;
_ctx = undefined;
}
return error;
},
};
}
Notice how failure() explicitly sets _issues = undefined and _ctx = undefined once error is materialized. Because raw issues hold references to iss.input and iss.inst, nulling out _issues and _ctx prevents long-lived SafeParseError objects from pinning raw input payloads in heap memory when reportInput is disabled.
2. ParsePayload and Bidirectional _zod.run (v4/core/schemas.ts)
2.1 ParsePayload<T> and ParseContextInternal (schemas.ts:L29-52)
// packages/zod/src/v4/core/schemas.ts
export interface ParseContextInternal extends ParseContext {
async?: boolean | undefined;
direction?: "forward" | "backward" | undefined;
skipChecks?: boolean | undefined;
abortEarly?: boolean | undefined;
}
export interface ParsePayload<T = unknown> {
value: T;
issues: errors.$ZodRawIssue[];
/**Flag toabort validation early Check execution stops if true. */
aborted?: boolean;
/**True if payload value has already been processed by a memoizer. */
memo?: boolean;
}
Instead of returning { status: "valid" | "dirty" | "aborted", value } wrapper objects at every recursion level (as Zod v3 did), Zod v4 passes a mutable ParsePayload object { value, issues } into schema._zod.run(payload, ctx):
- In-Place Value Mutation: Coercions, defaults, transforms, and
$ZodCheckOverwritechecks mutatepayload.valuedirectly and returnpayload. - Issue Accumulation: Validation failures push a lightweight
$ZodRawIssueontopayload.issues.
2.2 aborted() vs explicitlyAborted() (util.ts:L904-922)
Zod v4 distinguishes between two abort conditions using the continue flag on $ZodRawIssue:
// packages/zod/src/v4/core/util.ts:L904-922
export function aborted(x: schemas.ParsePayload, startIndex = 0): boolean {
if (x.aborted === true) return true;
for (let i = startIndex; i < x.issues.length; i++) {
if (x.issues[i]?.continue !== true) return true;
}
return false;
}
export function explicitlyAborted(x: schemas.ParsePayload, startIndex = 0): boolean {
if (x.aborted === true) return true;
for (let i = startIndex; i < x.issues.length; i++) {
if (x.issues[i]?.continue === false) return true;
}
return false;
}
- Base Type Parse Issues (
continue: undefined): When$ZodStringfails becausetypeof input !== "string", it pushes{ code: "invalid_type", expected: "string", input }withoutcontinue: true.util.aborted(payload)returnstrue, preventing downstream string checks (.min(3),.email()) from crashing on a non-string value. - Refinement Check Issues (
continue: !def.abort): Standard checks inchecks.tspush issues withcontinue: !def.abort(trueby default). Thus multiple checks on the same string or object all execute and report their issues together unless a check was configured with{ abort: true }(which setscontinue: false, triggeringexplicitlyAborted).
2.3 Forward vs Backward (encode) Wiring in $ZodType (schemas.ts:L218-291)
During schema construction, $ZodType registers a deferred callback that wires inst._zod.run:
- Zero-Check Fast Path: If
!checks || checks.length === 0,inst._zod.run = inst._zod.run ?? inst._zod.parse. Calling_zod.run(payload, ctx)invokes_zod.parse(payload, ctx)directly with zero indirection. - Forward Mode (
direction === "forward", default):- Calls
result = inst._zod.parse(payload, ctx). - When
resultresolves (synchronously or via.then()), callsrunChecks(result, checks, ctx).
- Calls
- Backward Mode (
direction === "backward", used byz.encode()):- Why can't backward mode simply run
checksbefore_zod.parse? Because checks (like.min(3)on a string or.refine(...)on an object) assume their input already matches the schema's output type! - Therefore,
$ZodTypefirst runs a canary pass:const canary = inst._zod.parse({ value: payload.value, issues: [] }, { ...ctx, skipChecks: true }); - If the canary pass fails (
util.aborted(canary)),_zod.runimmediately runsinst._zod.parse(payload, ctx)to record the type error and skipschecks. - If the canary pass succeeds,
_zod.runexecutesrunChecks(payload, checks, ctx)first, and then passes the checked payload intoinst._zod.parse(checkResult, ctx)(which reverses pipes, transforms, defaults, and codecs back to their input representation).
- Why can't backward mode simply run
3. $ZodObject Fast-Path Normalization, Discriminated Unions & $ZodCheck
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel
from drawlib.styles import Styles
from drawlib.text import text
setup(width=176, height=104)
text(
(88, 99.5),
"$ZodObject Shape Normalization, Discriminated Unions & Check Engine",
style=Styles.DarkBold.patch(text_size=13.5),
)
text(
(88, 95.5),
"How schemas.ts and checks.ts optimize object property loops, O(1) union dispatch, and check metadata bag attachment",
style=Styles.Muted.patch(text_size=10.0),
)
# ==============================================================================
# PANEL 1 (LEFT): $ZodObject Normalization & Security (x=5..58, y=6..91)
# ==============================================================================
p_obj = Panel(
"1. $ZodObject Shape & Security",
style=Styles.PrimaryNeutral,
header_style=Styles.Primary,
text_style=Styles.WhiteBold.patch(text_size=10.5),
icon=phosphor.cube,
icon_style=Styles.White,
header_height=6.0,
)
p_obj.draw(xy=(5, 6), width=53, height=85)
obj_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=9.5),
description_style=Styles.Muted.patch(text_size=9.5),
badge_shape="none",
direction="vertical",
spacing=1.5,
)
obj_cards.add(
"ShapeGetter.raw: Shallow Freeze",
description="Freezes { ...def.shape } on first read",
)
obj_cards.add(
"normalizeDef(def) via util.cached()",
description="Caches allKeys, symbolKeys, optionalKeys",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
obj_cards.add(
"optin / optout Optionality Rungs",
description="Tracks optionality across input & output",
style=Styles.PrimaryNeutral,
)
obj_cards.add(
"__proto__ Pollution Stripping",
description="Deletes undeclared own '__proto__' key",
style=Styles.AccentFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
obj_cards.add(
"handleCatchall: strip / strict",
description="Strips, errors, or validates extra keys",
)
obj_cards.draw(xy=(7.5, 8.5), width=48, height=74.5)
# ==============================================================================
# PANEL 2 (CENTER): Discriminated Unions & Codecs (x=61.5..114.5, y=6..91)
# ==============================================================================
p_union = Panel(
"2. O(1) Unions & Codecs",
style=Styles.Neutral,
header_style=Styles.Secondary,
text_style=Styles.WhiteBold.patch(text_size=10.5),
icon=phosphor.git_branch,
icon_style=Styles.White,
header_height=6.0,
)
p_union.draw(xy=(61.5, 6), width=53, height=85)
union_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=9.5),
description_style=Styles.Muted.patch(text_size=9.5),
badge_shape="none",
direction="vertical",
spacing=1.5,
)
union_cards.add(
"Lazy _zod.propValues Propagation",
description="Literals, enums & unions expose values",
style=Styles.PrimaryNeutral,
)
union_cards.add(
"DiscriminatedUnion O(1) Lookup",
description="Maps discriminator -> branch in O(1)",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
union_cards.add(
"Union Fallback on Backward Mode",
description="Falls back to sequential $ZodUnion scan",
)
union_cards.add(
"$ZodPipe & ZodCodec Routing",
description="Forward: in -> out; Backward: out -> in",
style=Styles.PrimaryNeutral,
)
union_cards.add(
"Strict Unrecognized Key Guard",
description="Aborts pipe on unrecognized_keys issue",
)
union_cards.draw(xy=(64.0, 8.5), width=48, height=74.5)
# ==============================================================================
# PANEL 3 (RIGHT): $ZodCheck Lifecycle & Bag (x=118..171, y=6..91)
# ==============================================================================
p_chk = Panel(
"3. $ZodCheck Engine & Bag",
style=Styles.PrimaryNeutral,
header_style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=10.5),
icon=phosphor.check_circle,
icon_style=Styles.White,
header_height=6.0,
)
p_chk.draw(xy=(118, 6), width=53, height=85)
chk_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=9.5),
description_style=Styles.Muted.patch(text_size=9.5),
badge_shape="none",
direction="vertical",
spacing=1.5,
)
chk_cards.add(
"Phase 1: _zod.onattach Callbacks",
description="Populates schema._zod.bag on attach",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=9.5),
description_style=Styles.White.patch(text_size=9.5),
)
chk_cards.add(
"Metadata Bag (minimum, pattern)",
description="Powers toJSONSchema() & regex merging",
style=Styles.PrimaryNeutral,
)
chk_cards.add(
"Phase 2: def.when(payload) Gate",
description="Skips check if precondition is false",
)
chk_cards.add(
"Phase 3: _zod.check(payload)",
description="Pushes raw issue or overwrites value",
)
chk_cards.add(
"attachSchema(inst, path, schema)",
description="Delegates nested property validations",
)
chk_cards.draw(xy=(120.5, 8.5), width=48, height=74.5)
save()
4. Deep Dive into $ZodObject, $ZodDiscriminatedUnion, and $ZodCheck
4.1 $ZodObject Shape Normalization and Security (schemas.ts:L2098-2269)
$ZodObject is the most performance-critical composite schema in Zod. Rather than calling Object.keys(def.shape) on every parse call, $ZodObject uses two caching layers:
- Frozen Shape Descriptor (
ShapeGetter.raw): On first read,ShapeGetter.rawclones and freezesdef.shape(Object.freeze({ ...inst._zod.def.shape })) and verifies that every property is an instance of$ZodType. - Cached
normalizeDef(def)(schemas.ts:L2098-2114): ComputesallKeys(string keys viaObject.keys(shape)),symbolKeys(Object.getOwnPropertySymbols(shape)),keySet(new Set(allKeys)), andoptionalKeys(keys whose child schema hasprop._zod.optin === "optional"). - Prototype-Pollution Defense (
schemas.ts:L2218-2222):
When parsing untrusted JSON payloads (e.g.,// Always strip own __proto__ unless explicitly declared in shape if (!keySet.has("__proto__") && Object.prototype.hasOwnProperty.call(input, "__proto__")) { delete (input as any)["__proto__"]; }JSON.parse('{"__proto__": {"isAdmin": true}}')),inputhas an own"__proto__"property. Even before constructing the output objectresult = {},$ZodObjectstrips undeclared own"__proto__"keys and writes validated properties ontoresultusingutil.assignProp(result, key, value)(which usesObject.definePropertywhenkey === "__proto__"). - Hybrid Sync/Async Object Continuation (
processObjectResults): During the loop overallKeys,$ZodObjecttrackslet isAsync = false. If every child property returns synchronously,resultis assigned in-place with zeroPromiseallocations. As soon as any child property returns aPromise,isAsyncflips totrue, and after the loop finishes,Promise.all(promises).then(() => processObjectResults(...))awaits only the async properties before runninghandleCatchall.
4.2 O(1) Lookup in $ZodDiscriminatedUnion (schemas.ts:L2695-2801)
How does $ZodDiscriminatedUnion support nested discriminated unions, objects with z.literal(), z.enum(), z.optional(), z.default(), and z.pipe() without requiring users to flatten their union branches manually?
- Every schema that constrains a finite set of primitive values defines
_zod.values(Set<Primitive>), and every$ZodObjector$ZodUnionaggregates its children's_zod.valuesinto a lazy_zod.propValuesmap (Record<string, Set<Primitive>>) viadefineLazyInternal. - When
$ZodDiscriminatedUnioninitializes, it readsopt._zod.propValues?.[def.discriminator]from every union option and populates aMap<Primitive, $ZodType>(discriminatorMap). - At parse time (
direction === "forward"),$ZodDiscriminatedUnionreadsconst discVal = payload.value[def.discriminator]and looks upconst match = discriminatorMap.get(discVal)inO(1)time! Ifmatchis found, it delegates directly tomatch._zod.run(payload, ctx). - If
ctx.direction === "backward"(where the discriminator property might be transformed by a pipe) or ifdef.unionFallbackis enabled because a branch has a non-statically-determinable discriminator,$ZodDiscriminatedUniongracefully falls back to$ZodUnion's sequential branch evaluation (inst._zod._super.parse(payload, ctx)).
4.3 The Two-Phase $ZodCheck Architecture (packages/zod/src/v4/core/checks.ts)
In Zod v3, refinements like .min(5) or .email() were hardcoded inside ZodString._parse. In Zod v4, every constraint is a standalone $ZodCheck trait defined in packages/zod/src/v4/core/checks.ts:
// packages/zod/src/v4/core/checks.ts:L55-76
export const $ZodCheck = /*@__PURE__*/ $constructor("$ZodCheck", (inst, def) => {
inst._zod ??= {} as any;
inst._zod.def = def;
inst._zod.onattach ??= [];
});
Each $ZodCheck subclass participates in two phases:
- Attachment Phase (
inst._zod.onattach): When a check is attached to a schema (inside$ZodTypeinitialization), every callback incheck._zod.onattachis called with the hostschema:$ZodCheckMinLengthwritesbag.minimum = def.minimum(orbag.minLength).$ZodCheckRegex/$ZodCheckStringFormatappends its regex tobag.patterns.- Numeric range checks (
$ZodCheckGreaterThan,$ZodCheckLessThan) updatebag.minimum/bag.maximum/bag.exclusiveMinimum/bag.exclusiveMaximum. This pre-populatesschema._zod.bagsotoJSONSchema()and introspection tools can read constraints inO(1)without executing or decompiling check closures!
- Execution Phase (
inst._zod.check(payload)): DuringrunChecks(payload, checks, ctx):- First, if
check._zod.def.whenis defined, Zod callscheck._zod.def.when(payload)(for example,$ZodCheckMinLengthsetswhen: (payload) => payload.value.length !== undefined) and skips the check ifwhenreturnsfalse. - Next,
check._zod.check(payload)runs. Validation checks push raw issues (payload.issues.push({ code: ..., input: payload.value, inst: check, continue: !def.abort })), whereas mutation checks like$ZodCheckOverwrite(z.overwrite(),.trim(),.toLowerCase()) overwritepayload.value = def.tx(payload.value)in place.
- First, if