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:
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:
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:
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:
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.tsrunsconfig(en())at import time. - Mini exposes the same operations as importable functions (
z.optional(schema), checks viaschema.check(z.minLength(5))), keeps only a minimal method surface (parsefamily,check,clone,register,brand— seeZodMiniTypeinv4/mini/schemas.ts), and registers no locale by default, so error messages fall back to"Invalid input"until you callz.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
- What runs at parse time: the parse pipeline.
- The generated-code path for objects: the object fast path.