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):

packages/zod/src/v4/classic/schemas.ts
// 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:

packages/zod/src/v4/classic/parse.ts
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:

packages/zod/src/v4/core/parse.ts
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:

packages/zod/src/v4/core/schemas.ts
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:

packages/zod/src/v4/core/schemas.ts
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:

packages/zod/src/v4/core/util.ts
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

Sources: packages/zod/src/v4/classic/parse.ts, packages/zod/src/v4/core/parse.ts, packages/zod/src/v4/core/schemas.ts · last synced 2026-08-10 · 2d90846 · version 4.4.3