Read OSS

The $constructor Pattern: How Zod Builds Types Without Classes

Advanced

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:

  1. A $ZodString (it validates string types)
  2. 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:

  1. First-call setup: The _zod internals object is created with a non-enumerable property descriptor (so it doesn't show up in JSON serialization)
  2. Trait deduplication: The traits Set prevents an initializer from running twice on the same instance — essential when diamond-shaped init chains occur
  3. 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 $constructor tells 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&lt;string&gt;<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: A Set<string> of trait names this instance implements. Used by Symbol.hasInstance.
  • def: The definition object passed to the constructor. Contains type, optional checks, and schema-specific config.
  • bag: A metadata object populated by check onattach callbacks. Stores computed values like minimum, maximum, format for O(1) access during JSON Schema generation.
  • parse: The core type validation function — checks data shape only, no refinements.
  • run: The full validation pipeline — calls parse, then runs all checks. Set up during $ZodType.init.
  • constr: Reference back to the constructor function, used by clone().
  • 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 $ZodType from core rather than ZodType from 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.