Inside the Validation Pipeline: Parsing, Checks, and Error Collection
Prerequisites
- ›Article 1: Architecture and Project Layout
- ›Article 2: The $constructor Pattern
- ›Understanding of sync/async JavaScript patterns and Promise handling
Inside the Validation Pipeline: Parsing, Checks, and Error Collection
When you call schema.parse(data), Zod doesn't simply check a type and return. It runs a multi-stage pipeline: the core type parse function verifies the data shape, then a sequence of attached checks validates constraints like min/max/regex. Errors don't throw eagerly — they accumulate in a mutable payload object that flows through the entire pipeline.
This article traces the complete flow, from the parse() entry point through _zod.run and _zod.parse, and into the check system. By the end, you'll understand exactly how validation happens at runtime.
The parse() Entry Point
All parsing starts in packages/zod/src/v4/core/parse.ts. The core _parse function creates the machinery:
export const _parse: (_Err: $ZodErrorClass) => $Parse =
(_Err) => (schema, value, _ctx, _params) => {
const ctx = _ctx ? Object.assign(_ctx, { async: false }) : { async: false };
const result = schema._zod.run({ value, issues: [] }, ctx);
if (result instanceof Promise) {
throw new core.$ZodAsyncError();
}
if (result.issues.length) {
const e = new (_params?.Err ?? _Err)(
result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))
);
throw e;
}
return result.value;
};
Three things happen here:
- A
ParsePayloadis created:{ value, issues: [] }— the mutable accumulator that flows through the entire pipeline schema._zod.run()is invoked — this is the full pipeline including checks- If issues accumulated, they're finalized (error messages resolved via error maps) and thrown as a
$ZodError
The Classic API provides a wrapped version in classic/parse.ts that uses ZodRealError (extending Error) instead of the base $ZodError (not extending Error), giving Classic users proper stack traces.
flowchart TD
A["schema.parse(data)"] --> B["Create ParsePayload<br/>{value: data, issues: []}"]
B --> C["schema._zod.run(payload, ctx)"]
C --> D{issues.length > 0?}
D -->|Yes| E["finalizeIssue() for each issue<br/>(resolve error messages)"]
E --> F["throw new $ZodError(issues)"]
D -->|No| G["return payload.value"]
The _zod.run Pipeline
The run function is set up during $ZodType.init at schemas.ts#L205-L300. Its behavior depends on whether the schema has checks attached.
No checks: When a schema has no checks, run is simply aliased to parse:
if (checks.length === 0) {
inst._zod.deferred?.push(() => {
inst._zod.run = inst._zod.parse;
});
}
This is a deferred callback because _zod.parse hasn't been set yet when $ZodType.init runs — it's defined by the more-specific initializer (like $ZodString.init) that runs afterward.
With checks: When checks exist, run becomes a pipeline that calls parse first, then runs checks:
sequenceDiagram
participant Caller
participant run as _zod.run
participant parse as _zod.parse
participant checks as runChecks
Caller->>run: payload, ctx
alt Forward direction (default)
run->>parse: payload, ctx
parse-->>run: payload (with value set)
run->>checks: payload, checks, ctx
checks-->>run: payload (with issues)
else Backward direction (encoding)
run->>parse: canary payload (empty issues)
parse-->>run: canary result
Note over run: Check if canary aborted
run->>checks: original payload
checks-->>run: payload (with issues)
run->>parse: checked payload
parse-->>run: final result
end
run-->>Caller: payload
The forward direction (normal parsing) is straightforward: parse, then check. The backward direction (encoding) is more complex — it runs a "canary" parse first to verify the data shape, then runs checks, then re-parses with the checked data. This reversal is essential for codecs where transforms need to run in the opposite direction.
The ParsePayload Pattern
Zod's approach to error collection is distinct from libraries that throw on the first error. The ParsePayload is a mutable object:
export interface ParsePayload<T = unknown> {
value: T;
issues: errors.$ZodRawIssue[];
aborted?: boolean;
}
Every validation function receives this payload and mutates it — pushing issues into the issues array and potentially replacing value with a transformed result. This design enables:
Multiple error reporting: All validation failures are collected rather than stopping at the first one. If an object has three invalid fields, the user sees all three errors at once.
Union trial-and-error: The $ZodUnion parse function at schemas.ts#L2121-L2179 tries each option with a fresh payload ({ value: payload.value, issues: [] }). If any option produces zero issues, that's the match:
inst._zod.parse = (payload, ctx) => {
for (const option of def.options) {
const result = option._zod.run(
{ value: payload.value, issues: [] }, ctx
);
if (result instanceof Promise) {
results.push(result); async = true;
} else {
if (result.issues.length === 0) return result; // match found
results.push(result);
}
}
// no match — report all failures
};
Value transformation: The payload's value field is mutable. Transforms replace it in-place, objects reconstruct it with validated properties, and defaults substitute it when undefined.
Tip: The
abortedflag onParsePayloadis distinct from having issues. A payload can have issues but not be aborted — meaning subsequent checks should still run. Theabortflag on individual checks controls whether later checks are skipped after that specific check fails.
Schema-Specific Parse Functions
Each schema type implements _zod.parse differently, but they all follow the same contract: receive a payload, validate or transform the value, push issues if invalid, return the payload.
Primitives like $ZodString do simple type checks:
inst._zod.parse = (payload, _) => {
if (typeof payload.value === "string") return payload;
payload.issues.push({
expected: "string", code: "invalid_type",
input: payload.value, inst,
});
return payload;
};
Objects at schemas.ts#L1861-L1936 iterate over the shape keys, running each field's _zod.run on the corresponding input property:
payload.value = {};
for (const key of value.keys) {
const el = shape[key]!;
const r = el._zod.run({ value: input[key], issues: [] }, ctx);
handlePropertyResult(r, payload, key, input, isOptionalOut);
}
The handlePropertyResult function merges sub-issues into the parent payload with path prefixing — this is how nested error paths like ["address", "zipCode"] are built.
Unions use the trial-and-error strategy described above, and include a fast-path optimization: if the union has only one option, it delegates directly without creating intermediate payloads.
flowchart TD
U["$ZodUnion.parse"] --> S{Single option?}
S -->|Yes| D["Delegate to only option"]
S -->|No| L["Try each option"]
L --> R1["Option 1: run with fresh payload"]
L --> R2["Option 2: run with fresh payload"]
L --> RN["Option N: ..."]
R1 --> C1{0 issues?}
C1 -->|Yes| RET["Return result"]
C1 -->|No| NEXT["Continue"]
NEXT --> R2
R2 --> C2{0 issues?}
C2 -->|No| FAIL["Collect all errors → invalid_union issue"]
The Check System: $ZodCheck, onattach, and Conditional Checks
Checks are built with $constructor just like schemas. The base $ZodCheck at checks.ts#L32-L39 is minimal:
export const $ZodCheck = core.$constructor("$ZodCheck", (inst, def) => {
inst._zod ??= {} as any;
inst._zod.def = def;
inst._zod.onattach ??= [];
});
Each check has three key properties:
_zod.check: The validation function that receives aParsePayloadand pushes issues_zod.onattach: An array of callbacks executed when the check is attached to a schema_zod.def.when: An optional predicate for conditional execution
The $ZodCheckLessThan at checks.ts#L64-L95 demonstrates the onattach pattern:
inst._zod.onattach.push((inst) => {
const bag = inst._zod.bag;
const curr = (def.inclusive ? bag.maximum : bag.exclusiveMaximum)
?? Number.POSITIVE_INFINITY;
if (def.value < curr) {
if (def.inclusive) bag.maximum = def.value;
else bag.exclusiveMaximum = def.value;
}
});
When this check is attached to a schema (during $ZodType.init), it eagerly updates the schema's _zod.bag with the maximum value. This metadata is then available for O(1) access during JSON Schema generation — no need to re-traverse the checks array.
The runChecks function iterates through checks, respecting the when predicate and abort flag:
for (const ch of checks) {
if (ch._zod.def.when) {
if (util.explicitlyAborted(payload)) continue;
const shouldRun = ch._zod.def.when(payload);
if (!shouldRun) continue;
} else if (isAborted) {
continue;
}
const _ = ch._zod.check(payload);
// handle async, update isAborted...
}
Custom checks are created via the _check and _superRefine helpers at api.ts#L1637-L1668, which wrap user functions in the check protocol.
Async Validation and $ZodAsyncError
Zod's pipeline is synchronous by default. When a check or transform returns a Promise, the system must detect this and react appropriately.
In synchronous mode (ctx.async === false), encountering a Promise triggers a $ZodAsyncError:
if (_ instanceof Promise && ctx?.async === false) {
throw new core.$ZodAsyncError();
}
The message is clear: "Encountered Promise during synchronous parse. Use .parseAsync() instead."
In async mode, Promises are chained together. The runChecks function accumulates an asyncResult Promise that serializes check execution:
if (asyncResult || _ instanceof Promise) {
asyncResult = (asyncResult ?? Promise.resolve()).then(async () => {
await _;
// update abort state...
});
}
This design avoids the overhead of Promise.all for the common case where all checks are synchronous, while gracefully handling the async case when it arises.
Tip: If you're building a Zod schema that might be used in both sync and async contexts, keep your custom refinements synchronous when possible. An async refinement forces all consumers to use
parseAsync(), which can cascade through an entire application.
What's Next
We've now traced the complete validation flow — from parse() through _zod.run, _zod.parse, and the check system. In the next article, we'll examine how Zod achieves high performance: the JIT compiler that generates unrolled object-parsing functions, the lazy initialization patterns (cached, defineLazy) that defer expensive work, and the _zod.bag metadata pattern that makes JSON Schema generation fast.