Zod v4 is built as three layers over one engine. Understanding the layering explains most file paths in packages/zod/src/v4 and why every internal property hangs off a single _zod namespace.

The architecture

zod/v4/core implements parsing, checks, errors, and JSON Schema conversion, with all internals under schema._zod. The two public APIs are skins over it: classic (zod) adds the chainable methods, mini (zod/mini) exposes the same schemas through standalone functions so bundlers can drop what you do not import.

flowchart TD
    subgraph API["public API layers"]
      classic["v4/classic\nZodString, .min(), .optional()"]
      mini["v4/mini\nfunction-based, tree-shakable"]
    end
    subgraph CORE["zod/v4/core"]
      ctor["core.ts\n$constructor trait system"]
      schemas["schemas.ts\n$ZodString ... $ZodObject"]
      checks["checks.ts\n$ZodCheckMinLength ..."]
      parse["parse.ts\n_parse / _safeParse drivers"]
      locales["locales/\n52 error maps"]
    end
    classic --> schemas
    mini --> schemas
    schemas --> ctor
    schemas --> checks
    parse --> schemas
    classic --> locales

The entry file packages/zod/src/index.ts is three lines re-exporting v4/classic/external.js — the npm package zod is the classic layer.

$constructor: classes without class hierarchies

Every schema type is defined with $constructor(name, initializer) from core.ts instead of a plain class. It returns a constructor whose init can also be applied to an existing instance, which is how one object accumulates several traits:

packages/zod/src/v4/core/core.ts
function init(inst: T, def: D) {
  if (!inst._zod) {
    Object.defineProperty(inst, "_zod", {
      value: {
        def,
        constr: _,
        traits: new Set(),
      },
      enumerable: false,
    });
  }

  if (inst._zod.traits.has(name)) {
    return;
  }

  inst._zod.traits.add(name);

  initializer(inst, def);
  // …
}

instanceof is overridden to consult the trait set rather than the prototype chain:

packages/zod/src/v4/core/core.ts
Object.defineProperty(_, Symbol.hasInstance, {
  value: (inst: any) => {
    if (params?.Parent && inst instanceof params.Parent) return true;
    return inst?._zod?.traits?.has(name);
  },
});

This is why, in codec.test.ts, one value satisfies instanceof z.ZodCodec, instanceof z.ZodPipe, instanceof z.ZodType, and the three corresponding z.core.$Zod* classes at once: the classic ZodCodec initializer calls core.$ZodCodec.init and ZodType.init on the same instance, stacking traits. It also means a schema created by one copy of Zod passes instanceof checks from another copy — important in monorepos with duplicated packages.

A schema is then just a def (plain data: type name, checks array, params) plus initializer-installed behavior. The classic z.object() factory shows the shape:

packages/zod/src/v4/classic/schemas.ts
export function object<T extends core.$ZodLooseShape = Partial<Record<never, core.SomeType>>>(
  shape?: T,
  params?: string | core.$ZodObjectParams
): ZodObject<util.Writeable<T>, core.$strip> {
  const def: core.$ZodObjectDef = {
    type: "object",
    shape: shape ?? {},
    ...util.normalizeParams(params),
  };
  return new ZodObject(def) as any;
}

Checks attach data as well as behavior

A check is its own trait with a check(payload) function and an onattach list. When a schema is constructed, $ZodType.init runs every check's onattach hooks — checks use this to record facts about the schema in _zod.bag, which later feeds JSON Schema generation and the internal pattern/minimum/maximum metadata:

packages/zod/src/v4/core/checks.ts
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;
  }
});

inst._zod.check = (payload) => {
  if (def.inclusive ? payload.value <= def.value : payload.value < def.value) {
    return;
  }

  payload.issues.push({
    origin,
    code: "too_big",
    maximum: typeof def.value === "object" ? def.value.getTime() : def.value,
    input: payload.value,
    inclusive: def.inclusive,
    inst,
    continue: !def.abort,
  });
};

Chaining methods never mutate: .check() clones the schema with the new check appended to def.checks (see _installLazyMethods in classic/schemas.ts), so schemas stay immutable and shareable.

Classic vs mini, concretely

Both layers construct the identical core schemas; they differ in ergonomics and bundle cost.

  • Classic installs builder methods (optional, refine, transform, about forty of them) as lazy-bound getters on the prototype, and its entry point eagerly loads the English locale: packages/zod/src/v4/classic/external.ts runs config(en()) at import time.
  • Mini exposes the same operations as importable functions (z.optional(schema), checks via schema.check(z.minLength(5))), keeps only a minimal method surface (parse family, check, clone, register, brand — see ZodMiniType in v4/mini/schemas.ts), and registers no locale by default, so error messages fall back to "Invalid input" until you call z.config(z.locales.en()).

The repo measures the difference explicitly — packages/treeshake bundles fixtures like zod-string.ts vs zod-mini-string.ts with Rollup and esbuild; see benchmarks and bundle size.

Related pages

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