This page traces one call — z.string().min(5).safeParse(input) — from the public method down to the returned result. Every step names the file that implements it.
The user's view
From the caller's side the flow is small and fixed: define a schema, hand it untrusted input, branch on the result.
flowchart TD
A["Define schema\nz.object({ name: z.string().min(5) })"] --> B["schema.safeParse(input)\nor schema.parse(input)"]
B --> C{"issues.length == 0?"}
C -->|yes| D["Typed data\nz.infer<typeof schema>"]
C -->|no| E["ZodError\nissues: code, path, message"]
E -->|"parse()"| F["thrown"]
E -->|"safeParse()"| G["{ success: false, error }"]
D -->|"safeParse()"| H["{ success: true, data }"]
Step 1: the instance method is a thin closure
Every classic schema gets its parse family assigned at construction time, as per-instance closures (the comment explains why):
// Parse-family is intentionally kept as per-instance closures: these are
// the hot path AND the most-detached methods (`arr.map(schema.parse)`,
// `const { parse } = schema`, etc.). Eager closures here mean callers pay
// ~12 closure allocations per schema but get monomorphic call sites and
// detached usage that "just works".
inst.parse = (data, params) => parse.parse(inst, data, params, { callee: inst.parse });
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
These delegate to packages/zod/src/v4/classic/parse.ts, which binds the core implementations to the classic ZodError class:
export const safeParse: <T extends core.$ZodType>(
schema: T,
value: unknown,
_ctx?: core.ParseContext<core.$ZodIssue>
) => ZodSafeParseResult<core.output<T>> = /* @__PURE__ */ core._safeParse(ZodRealError) as any;
Step 2: the core driver runs the schema
The engine is _parse/_safeParse in the core. It wraps the input in a payload — { value, issues: [] } — and calls the schema's _zod.run:
export const _parse: (_Err: $ZodErrorClass) => $Parse = (_Err) => (schema, value, _ctx, _params) => {
const ctx: schemas.ParseContextInternal = _ctx ? { ..._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())));
util.captureStackTrace(e, _params?.callee);
throw e;
}
return result.value as core.output<typeof schema>;
};
Two things to notice: success and failure are both communicated through the payload (issues accumulates; nothing is thrown inside the pipeline), and the sync driver detects async work by checking for a Promise and refuses it.
Step 3: run = type parse + checks
_zod.run is assembled per instance by the $ZodType base constructor. If the schema has no checks, run is just the type parser. With checks attached, run executes the type parser and then the check list:
inst._zod.run = (payload, ctx) => {
if (ctx.skipChecks) {
return inst._zod.parse(payload, ctx);
}
// …
// forward
const result = inst._zod.parse(payload, ctx);
if (result instanceof Promise) {
if (ctx.async === false) throw new core.$ZodAsyncError();
return result.then((result) => runChecks(result, checks, ctx));
}
return runChecks(result, checks, ctx);
};
The type parser for a string is representative — coerce if configured, accept, or push an invalid_type issue:
inst._zod.parse = (payload, _) => {
if (def.coerce)
try {
payload.value = String(payload.value);
} catch (_) {}
if (typeof payload.value === "string") return payload;
payload.issues.push({
expected: "string",
code: "invalid_type",
input: payload.value,
inst,
});
return payload;
};
runChecks walks the attached checks (min(5) is a $ZodCheckMinLength) in order. Failing checks push issues tagged continue: !def.abort — and abort defaults to false — so several checks can each contribute an issue in one pass. An issue without continue: true marks the payload aborted (util.aborted), and subsequent checks are then skipped unless they declare a when predicate. That is why an invalid_type failure from the type stage suppresses all check errors, while two failing .refine() calls both appear in the issue list.
Step 4: issues become a ZodError
Raw issues carry references to the schema instance (inst) and the raw input. Only at the boundary does finalizeIssue resolve each into the public shape — and this is where the error-message precedence chain lives:
const message = iss.message
? iss.message
: (unwrapMessage(iss.inst?._zod.def?.error?.(iss as never)) ??
unwrapMessage(ctx?.error?.(iss as never)) ??
unwrapMessage(config.customError?.(iss)) ??
unwrapMessage(config.localeError?.(iss)) ??
"Invalid input");
Schema-level error beats the per-call error map, which beats the global customError, which beats the active locale. The full story is on the error pipeline page.
parse throws the resulting ZodError; safeParse returns { success: false, error }. On the happy path, result.value is returned as core.output<T> — the same type z.infer names.
Async and encode variants
parseAsync/safeParseAsync run the identical pipeline with ctx.async: true and await promise payloads. encode/decode (for codecs) reuse _parse with ctx.direction: "backward", which makes run do a checks-free "canary" pass first (packages/zod/src/v4/core/schemas.ts, handleCanaryResult) so that checks validate the input-side value in reverse mode. See JSON Schema and codecs for usage.
Related pages
- How
runandparseget installed on instances in the first place: schema construction. - Why object parsing compiles its own code at runtime: the object fast path.