The $constructor Pattern: How Zod Builds Types Without Classes
Prerequisites
- ›Article 1: Architecture and Project Layout
- ›TypeScript conditional types, mapped types, and the infer keyword
- ›JavaScript Symbol.hasInstance and Object.defineProperty
- ›Understanding of mixin and trait composition patterns
The $constructor Pattern: How Zod Builds Types Without Classes
If you've ever tried to read Zod's source code and felt confused by the absence of class Foo extends Bar declarations, you're not alone. Zod v4 deliberately avoids ES6 class inheritance in favor of a custom $constructor factory function that enables something impossible with single-inheritance classes: a schema that is simultaneously a type validator and a check.
This pattern is the single most important abstraction in the entire codebase. Every schema, every check, and every error object is built with $constructor. Understanding it unlocks the rest of the code.
Why Not ES6 Classes?
Consider $ZodStringFormat — a schema that validates strings matching a specific format (like email or uuid). It needs to be:
- A
$ZodString(it validates string types) - A
$ZodCheckStringFormat(it applies format-specific validation as a check)
In a traditional class hierarchy, you'd need multiple inheritance:
flowchart TD
A["$ZodType"] --> B["$ZodString"]
A --> C["$ZodCheck"]
C --> D["$ZodCheckStringFormat"]
B --> E["$ZodStringFormat ❌<br/>Can't extend both B and D"]
D --> E
JavaScript doesn't support multiple inheritance. Mixins are one workaround, but they're awkward with TypeScript's type system and lose instanceof support. Zod's $constructor solves this with a trait-based approach where each "constructor" is really an initializer that runs against the same object, and instanceof checks are based on a Set of trait names rather than prototype chains.
The $constructor Implementation
The entire pattern lives in packages/zod/src/v4/core/core.ts#L17-L77. Let's walk through it piece by piece.
The function signature:
export function $constructor<T extends ZodTrait, D = T["_zod"]["def"]>(
name: string,
initializer: (inst: T, def: D) => void,
params?: { Parent?: typeof Class }
): $constructor<T, D>
It takes a name (the trait identifier), an initializer function, and an optional parent class (used only for error types that need to extend Error).
The init function is the heart of the pattern:
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; // prevent double-init
}
inst._zod.traits.add(name);
initializer(inst, def);
}
Three critical things happen here:
- First-call setup: The
_zodinternals object is created with a non-enumerable property descriptor (so it doesn't show up in JSON serialization) - Trait deduplication: The
traitsSet prevents an initializer from running twice on the same instance — essential when diamond-shaped init chains occur - Initializer execution: The actual setup logic runs
The constructor function itself:
function _(this: any, def: D) {
const inst = params?.Parent ? new Definition() : this;
init(inst, def);
inst._zod.deferred ??= [];
for (const fn of inst._zod.deferred) { fn(); }
return inst;
}
After initialization, any deferred callbacks are flushed. This is how the run function is set up after parse has been defined — a topic we'll cover in Article 3.
Finally, Symbol.hasInstance is overridden:
Object.defineProperty(_, Symbol.hasInstance, {
value: (inst: any) => {
if (params?.Parent && inst instanceof params.Parent) return true;
return inst?._zod?.traits?.has(name);
},
});
This means schema instanceof $ZodString works by checking whether the _zod.traits Set contains "$ZodString" — not by walking the prototype chain. A $ZodStringFormat instance has both "$ZodString" and "$ZodCheckStringFormat" in its traits, so it passes instanceof checks for both.
Tip: The
/*@__NO_SIDE_EFFECTS__*/annotation before$constructortells bundlers this function call is safe to tree-shake if the result is unused. This annotation appears throughout the codebase and is critical for Mini's bundle size.
Tracing the Init Chain: z.string()
What happens when you call z.string()? The answer involves four layers of initialization. Let's trace it through the Classic API:
sequenceDiagram
participant User
participant Factory as z.string() factory
participant ZodString as ZodString (Classic)
participant CoreString as $ZodString (Core)
participant CoreType as $ZodType (Core)
User->>Factory: z.string()
Factory->>ZodString: new _ZodString({ type: "string" })
ZodString->>CoreString: $ZodString.init(inst, def)
CoreString->>CoreType: $ZodType.init(inst, def)
Note over CoreType: Sets up _zod.run, _zod.bag,<br/>Standard Schema, deferred init
Note over CoreString: Sets up _zod.parse<br/>(string type check)
Note over ZodString: Attaches .optional(), .transform(),<br/>.refine(), .toJSONSchema(), etc.
ZodString-->>User: Fully initialized schema
Step 1 — Factory function: z.string() in the Classic API calls a factory function (defined in api.ts) that creates new _ZodString({ type: "string" }).
Step 2 — $ZodString.init: The core string initializer at schemas.ts#L352-L372 first calls $ZodType.init(inst, def), then sets up the string-specific parse function:
export const $ZodString = core.$constructor("$ZodString", (inst, def) => {
$ZodType.init(inst, def);
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;
};
});
Step 3 — $ZodType.init: The base initializer at schemas.ts#L185-L315 does the heavy lifting: it initializes _zod.bag, processes attached checks via their onattach callbacks, and sets up _zod.run (the full validation pipeline including checks).
Step 4 — Classic ZodType.init: The Classic wrapper at classic/schemas.ts#L156-L259 calls core.$ZodType.init(inst, def) again (but the trait dedup prevents double execution), then attaches all the fluent methods:
inst.parse = (data, params) => parse.parse(inst, data, params, { callee: inst.parse });
inst.optional = () => optional(inst);
inst.transform = (tx) => pipe(inst, transform(tx));
// ... and many more
The _zod Internals Object
Every schema instance carries a _zod object with a well-defined structure. Understanding its fields is essential for reading the rest of the codebase:
flowchart LR
subgraph "_zod Internals"
direction TB
traits["traits: Set<string><br/>Trait identifiers"]
def["def: $ZodTypeDef<br/>Schema configuration"]
bag["bag: Record<br/>Metadata from checks"]
parse["parse: fn<br/>Core type validation"]
run["run: fn<br/>Full pipeline (parse + checks)"]
constr["constr: Constructor<br/>For cloning"]
deferred["deferred: fn[]<br/>Post-init callbacks"]
values["values?: Set<br/>Literal value set"]
pattern["pattern?: RegExp<br/>String pattern"]
end
Key fields from schemas.ts#L91-L149:
traits: ASet<string>of trait names this instance implements. Used bySymbol.hasInstance.def: The definition object passed to the constructor. Containstype, optionalchecks, and schema-specific config.bag: A metadata object populated by checkonattachcallbacks. Stores computed values likeminimum,maximum,formatfor O(1) access during JSON Schema generation.parse: The core type validation function — checks data shape only, no refinements.run: The full validation pipeline — callsparse, then runs all checks. Set up during$ZodType.init.constr: Reference back to the constructor function, used byclone().values: An optional Set of literal values that pass validation (used by enums, literals, and discriminated unions).
The distinction between Classic and Mini is visible here: Classic's ZodType.init attaches methods like .optional() and .transform() directly to the instance, while Mini's ZodMiniType.init only attaches .parse, .safeParse, .check, and .clone.
Type Inference: input and output
Zod's TypeScript magic — inferring static types from runtime schemas — is built on two type helpers defined in core.ts#L117-L120:
export type input<T> = T extends { _zod: { input: any } }
? T["_zod"]["input"] : unknown;
export type output<T> = T extends { _zod: { output: any } }
? T["_zod"]["output"] : unknown;
export type { output as infer };
These conditional types extract the input and output types from any schema's _zod internals. The output type is aliased as infer — this is what powers z.infer<typeof mySchema>.
For simple schemas like $ZodString, input and output are both string. But for transforms, pipes, and codecs, they diverge: the input type is what goes in to .parse(), and the output type is what comes out.
flowchart LR
subgraph "$ZodString"
SI["input: string"] --> SO["output: string"]
end
subgraph "$ZodPipe(string → number)"
PI["input: string"] --> PO["output: number"]
end
subgraph "$ZodDefault(string)"
DI["input: string | undefined"] --> DO["output: string"]
end
The branding system at core.ts#L80-L96 uses a unique symbol $brand to add phantom type brands without affecting runtime behavior:
export const $brand: unique symbol = Symbol("zod_brand");
export type $brand<T> = { [$brand]: { [k in T]: true } };
This enables patterns like z.string().brand<"UserId">() where the output type becomes string & { [Symbol("zod_brand")]: { UserId: true } } — a type that's incompatible with plain string at the type level but identical at runtime.
How Classic and Mini Share Core
The relationship between the API surfaces and core is one of composition, not inheritance. Both Classic's ZodType and Mini's ZodMiniType call core.$ZodType.init(inst, def) as their first action. This is the init chain pattern in action — the core initializer runs exactly once (thanks to trait deduplication), and then the API surface adds its own methods.
This means a schema created by Classic and a schema created by Mini share the same _zod.run and _zod.parse implementations. The validation logic is identical. The only difference is which convenience methods are attached to the instance.
Tip: If you're writing a Zod plugin or extension, target
$ZodTypefrom core rather thanZodTypefrom Classic. This ensures your code works with both API surfaces.
What's Next
Now that you understand how schemas are constructed — the $constructor pattern, the init chain, the _zod internals — we're ready to trace what happens when you actually use a schema. In the next article, we'll follow a .parse() call from start to finish: through the ParsePayload accumulation pattern, the _zod.run pipeline, and the check system with its onattach callbacks and conditional execution.