Home / Articles / How a Getter Bug Quietly Cost Zod 3x Runtime Performance

This article is published in English.

How a Getter Bug Quietly Cost Zod 3x Runtime Performance

An inside look at how TypeScript's CommonJS getter emission blocked V8 inlining in Zod, plus what changed across the broader Zod 4 rewrite.

1719 words

The Bug: Getters Are Invisible to the JIT

When TypeScript compiles a re-export statement like export * from './schemas', it doesn't simply copy values over. Instead, it generates a getter for every exported name — a small function that executes each time the property is read, rather than exposing a plain static property holding the value directly.

Normally that's an implementation detail nobody notices. In Zod's case it mattered a lot: 252 out of the 255 exports on Zod 4.5's CommonJS entry point were implemented as getters. V8's JIT is excellent at inlining — swapping a function call for the function's actual body so the engine skips call overhead — but only when it can guarantee the target function is stable and predictable. A getter breaks that guarantee. V8 has no way to see a fixed, unchanging function hiding behind a getter, so it can't safely inline anything reached that way.

The fix in Zod 4.6 sounds almost too simple: emit ordinary properties instead of getters, and freeze the resulting exports object so V8 knows nothing about it will ever change. Here's what that path looks like in practice:

// CommonJS require — this is the path that was affected
const { z } = require("zod");
const CompiledPlayer = z.compile(Player);
z.validate(CompiledPlayer, data);
// ~3x faster in Zod 4.6 than the identical call in Zod 4.

It's worth being precise about how narrow this fix actually is, since it's easy to exaggerate its reach. Only calls routed through the namespace object — things like z.validate(...) or z.compile(...) — were affected, and only when using require(). Calling a method directly on a schema instance, such as Player.safeParse(data), never touches the exports object at all, so that pattern was never impacted. The ESM build was completely unaffected; this was strictly a quirk of CommonJS re-exports.

The broader takeaway extends well past Zod itself: the shape your compiler outputs carries real runtime consequences that have nothing to do with the logic you actually wrote. Nobody running Zod 4.5 was writing inferior code compared to someone on 4.6 — the identical z.validate() call simply got faster because a separate tool, the TypeScript compiler, happened to structure its emitted output differently.

The Bigger Rewrite Behind It

That fix is a small piece of a much larger effort: Zod 4, stable as of 2025, is a rewrite from the ground up, and the headline speed gains are substantial in their own right. Independent testing found parsing plain strings running roughly fourteen times quicker, arrays coming in around seven times quicker, and object parsing landing near six and a half times quicker, all measured against Zod 3. But the change most likely to affect your daily workflow isn't about runtime speed at all: TypeScript compiler instantiations for a typical schema fell from over 25,000 to around 175 — which explains why editors and type-checking used to lag on large Zod-heavy codebases, and frequently no longer do.

For situations where bundle size is critical — edge functions, client-side widgets — Zod Mini provides the same set of validators through a fully tree-shakeable, functional interface rather than Zod's familiar chained-method style:

// Standard Zod — method chaining
import * as z from "zod";
const User = z.object({ name: z.string(), age: z.number() });
// Zod Mini - same validators, functional style, smaller bundle
import * as z from "zod/mini";
const User = z.object({ name: z.string(), age: z.number() });

What Actually Changed in the API

This is the section where a simple npm install zod@^4 can quietly break existing code, so it's worth going through each change directly instead of relying on a changelog summary.

String format validators became top-level, tree-shakeable functions:

// Zod 3 style — deprecated, but still works
const schema = z.string().email();
// Zod 4 - the new standard
const schema = z.email();
const id = z.uuid();
const site = z.url();

Four separate mechanisms for customizing error messages were merged into a single option:

// ❌ Zod 3 — three different mechanisms
const schema = z.string({
  required_error: "Name is required",
  invalid_type_error: "Name must be a string",
});
const age = z.number({
  errorMap: (issue, ctx) => {
    if (issue.code === "too_small") return { message: "Must be 18+" };
    return { message: ctx.defaultError };
  },
});
// ✅ Zod 4 - one parameter, string or function
const schema = z.string({ error: "Name is required" });
const age = z.number({
  error: (issue) => {
    if (issue.code === "too_small") return "Must be 18+";
    return "Invalid age";
  },
});

Error formatting was pulled off the error object and turned into standalone helper functions:

const result = User.safeParse(input);
if (!result.success) {
  result.error.issues;              // the raw array - was .errors in Zod 3
  z.treeifyError(result.error);     // nested shape, replaces .format()
  z.flattenError(result.error);     // { formErrors, fieldErrors }, replaces .flatten()
  z.prettifyError(result.error);    // human-readable string, great for logs
}

A typical API route handler written against Zod 4 ends up looking like this:

app.post("/users", (req, res) => {
  const result = User.safeParse(req.body);
  if (!result.success) {
    const { fieldErrors } = z.flattenError(result.error);
    return res.status(400).json({ errors: fieldErrors });
  }
  // result.data is fully typed here
  createUser(result.data);
});

The Subtle Trap Worth Flagging Specifically

Two of the changes introduced in Zod 4 belong to a particular category of danger: they slip past code review without raising any flags, then surface as production bugs weeks later. Both deserve to be called out individually rather than buried in a list.

ZodError.errors is gone, replaced by .issues. If any of your existing error-handling logic still reads error.errors, nothing throws. It just quietly evaluates to undefined. That kind of failure sails straight through any test suite that doesn't explicitly assert on that specific property, and only becomes visible once a real user hits it in production.

The precedence of contextual error messages was reversed. Under Zod 3, an error override supplied at parse time took priority over one defined on the schema itself. Under Zod 4, that priority is flipped: the schema-level message now wins.

const mySchema = z.string({ error: () => "Schema-level error" });
// Zod 3: this override wins → "Contextual error"
// Zod 4: the schema-level error wins instead → "Schema-level error"
mySchema.parse(12, { error: () => "Contextual error" });

Nothing about the call site changes, yet the same code returns a different message depending purely on which major version is installed — a behavioral reversal hiding behind what looks like a simple naming update.

The Honest Competitive Picture

It's tempting to read the 4.6 fix and the broader rewrite as proof that Zod now beats every other validation library outright, but the actual numbers call for a more measured conclusion. Running one million validations against a nested, eight-field object on an M3 Pro machine, ArkType completes in roughly 820ms, Valibot in about 1,140ms, and Zod 4 in around 1,380ms. For context, Zod 3 needed about 4,200ms to do the same work, so the rewrite is a genuine, substantial gain over its own predecessor even if it doesn't top the field. On bundle size, Valibot keeps a large lead: a typical login-form schema weighs in around 1.37KB with Valibot, compared to about 17.7KB with standard Zod, and still close to 7KB even using Zod Mini.

The more useful conclusion from those same figures is that at a throughput of one million validations per second — well beyond what any realistic API endpoint needs to sustain — the performance gap between these three libraries translates to a few hundred milliseconds spread across a million calls. That difference is not something normal production traffic will ever notice. For Node.js services and codebases built heavily around tRPC, Zod's deeper ecosystem support and its familiar chained-method style will typically matter more in daily use than which library wins a synthetic benchmark. For situations where bundle size is genuinely the constraint — edge functions, or validators shipped to the client — Valibot's size advantage is the factor that actually decides things, independent of how fast any of these libraries validate.

Practical Migration Guidance

Before anything else, confirm you're on TypeScript 5.5 or later, since Zod 4 requires it. The deprecated methods carried over from Zod 3 continue to function, only emitting runtime warnings, which is exactly why most teams migrate gradually, file by file, instead of attempting one risky, all-at-once cutover. The single highest-value step to take first is a codebase-wide search for .errors, .format(), and .flatten() used on Zod error objects, since these are precisely the changes that fail quietly rather than loudly. And if your project is already on Zod 4 but runs through Node's CommonJS require() path — still common in backend setups even inside codebases that are otherwise ESM — upgrading specifically to 4.6 is close to a free performance improvement, since the fix requires zero changes to your own code.

The Actual Takeaway

The story behind the 4.6 fix is small, but the lesson attached to it is bigger: what you actually run in production is only half determined by the code you write. The other half is decided by whatever your compiler and bundler choose to emit on your behalf, and that emitted layer carries its own performance behavior that has nothing to do with how carefully your own logic was written. Most of the time, you can safely ignore that layer entirely. But every so often — as with 252 getter methods quietly blocking V8's inliner for well over a year — it's worth remembering that "my code is correct" and "my code compiles down to something fast" are two separate claims. The second one is worth verifying occasionally, even when nothing you did was actually wrong.