npm stats
  • Search
  • About
  • Repo
  • Sponsor
  • more
    • Search
    • About
    • Repo
    • Sponsor

Made by Antonio Ramirez

zod-compiler

1.14.0

@GitHub Actions

npmHomeRepoSnykSocket
Downloads:13659
$ npm install zod-compiler
DailyWeeklyMonthlyYearly

zod-compiler

Compile Zod schemas into zero-overhead validation functions at build time.

Keep your existing Zod schemas. Get 2-75x faster validation. No code changes required.

  • What Gets Compiled
  • Schema Hoisting
  • Benchmark

[!NOTE] zod-compiler has been tested to work in large projects with tens of thousands of Zod schemas.

Usage

There are three ways to use zod-compiler. Choose the one that fits your project.

1. Automatic Mode (Default)

The plugin automatically detects and compiles all exported Zod schemas at build time. No wrappers, no imports from zod-compiler in your source code.

vite.config.ts:

import zodCompiler from "zod-compiler/vite";

export default defineConfig({
  plugins: [zodCompiler()],
});

Your schema file stays pure Zod:

// src/schemas.ts
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "editor", "viewer"]),
});

export const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  email: z.email().optional(),
});

export const ListUsersSchema = z.object({
  page: z.number().int().min(1).optional().default(1),
  limit: z.number().int().min(1).max(100).optional().default(20),
});

Use them as usual:

const user = CreateUserSchema.parse(data); // throws on failure
const result = CreateUserSchema.safeParse(data); // { success, data/error }

Zero-allocation type guard — .is(): compiled schemas also expose an .is(input): input is T boolean guard. For the common case (objects, primitives, arrays, enums with no coerce/default/catch/transform) this is the compiled fast-check — one boolean expression, no SafeParseResult, no issues array — the cheapest possible "does this match?" check, on par with typia's is<T>() and a clean replacement for schema.safeParse(x).success:

if (CreateUserSchema.is(data)) {
  data.email; // narrowed to the schema's output type
}
const valid = items.filter((x) => CreateUserSchema.is(x));

Schemas without a total fast path fall back to safeParse(input).success (still correct). The guard is also available on compile()-wrapped schemas (Zod's runtime fallback before the build).

At build time, the plugin:

  1. Finds every file with import ... from "zod" (skips type-only imports)
  2. Statically pre-filters: files whose exports provably can't be schemas (functions, components, constants) are skipped without ever being executed
  3. Executes the remaining candidates and detects exported Zod schemas
  4. Compiles each schema into an optimized validator
  5. Replaces the export with a tree-shakeable IIFE that preserves the full Zod API

What "preserves the full Zod API" means: The optimized parse/safeParse/parseAsync/safeParseAsync methods (plus the .is() guard) are installed directly on the original schema object, which is exported as-is. Identity is preserved, so ._zod, .shape, Standard Schema (~standard), instanceof, .meta() / z.globalRegistry, and z.toJSONSchema() all still work. Libraries that accept Zod schemas (tRPC, Hono, React Hook Form) work without changes.

2. compile() (Explicit)

If you prefer explicit opt-in, wrap specific schemas with compile():

import { z } from "zod";
import { compile } from "zod-compiler";

const UserSchema = z.object({
  name: z.string().min(3),
  email: z.email(),
});

export const validateUser = compile(UserSchema);

// In dev: falls back to Zod's runtime validation
// After build: uses AOT-compiled optimized code
validateUser.parse(data);
validateUser.safeParse(data);

compile() and auto mode coexist — compile() schemas are detected first, then every remaining plain Zod export is picked up. To make compile() the only path (no automatic detection, no build-time execution of plain schema files), pair it with schemas: "explicit" in the plugin options.

3. CLI (No Bundler)

Generate optimized validation files from the command line:

# Single file
npx zod-compiler generate src/schemas.ts -o src/schemas.compiled.ts

# Directory
npx zod-compiler generate src/ -o src/compiled/

# Watch mode
npx zod-compiler generate src/ --watch

# Only compile() calls (skip plain exports); minimal methods-only output
npx zod-compiler generate src/ --schemas explicit --emit bag

# Compact output: fast path only, cold errors delegated to Zod (~70% smaller)
npx zod-compiler generate src/ --emit compact

# Strip unknown keys from z.object() output (matches Zod's default .parse())
npx zod-compiler generate src/ --strip-unknown-keys

Build Plugin

Supported Build Tools

Build ToolImport
Viteimport zodCompiler from "zod-compiler/vite"
webpackimport zodCompiler from "zod-compiler/webpack"
esbuildimport zodCompiler from "zod-compiler/esbuild"
Rollupimport zodCompiler from "zod-compiler/rollup"
Rolldownimport zodCompiler from "zod-compiler/rolldown"
rspackimport zodCompiler from "zod-compiler/rspack"
Bunimport zodCompiler from "zod-compiler/bun"
Farmimport zodCompiler from "zod-compiler/farm"

Options

OptionTypeDefaultDescription
schemas"auto" | "explicit""auto"How schemas are found. "auto": every exported Zod schema compiles (also enables compiling hoisted in-function schemas). "explicit": only compile()-wrapped schemas; only files importing zod-compiler execute at build time
includestring[]—Only process files matching these path globs (picomatch, matched anywhere in the path; plain substrings work too)
excludestring[]—Skip files matching these path globs (same matching rules as include)
output"schema" | "bag" | "compact""schema"What a compiled export evaluates to. "schema": the original Zod schema with compiled methods installed (full API preserved). "bag": a minimal methods-only object — smaller bundles, breaks Zod-schema consumers. "compact": like "schema" but only the fast path is compiled — cold errors delegate to the retained Zod schema, dropping the slow walk (~70% smaller output, hot path unchanged). See Compact Output
verbosebooleanfalseLog per-schema compilation status during build
hoistbooleantrueHoist Zod schemas defined inside function bodies to module scope so they're constructed once instead of per call (babel-plugin-zod-hoist equivalent). Only expressions built purely from imports and literals are hoisted
apply"build" | "serve" | "all"builds + VitestVite only: when the plugin runs. By default, production builds and test runs are compiled (so tests exercise what ships); plain dev servers use the Zod fallback. "all" also compiles the dev server; "build" also skips tests
codegenMode"lean" | "inline"autoOverride the codegen mode. "lean" (default for all supported bundlers): shared runtime helpers are imported from virtual:zod-compiler/runtime, which the bundler resolves via its module hooks. "inline": helpers are emitted directly into each transformed file — use this for transpile-only esbuild builds (no --bundle) or similar setups where the bundler's hooks never fire for already-transformed output and the virtual: specifier would survive into dist/
stripUnknownKeysbooleanfalseStrip unknown keys from z.object() output, matching Zod's default .parse(). Off by default (a valid object is returned by reference, keeping extras). When on, genuine z.object() schemas rebuild a fresh object with only the declared keys; z.looseObject() still keeps extras and z.strictObject() still rejects them. Use it to sanitize untrusted input against mass-assignment. See Behavioral Differences
cacheboolean | stringtruePersistent transform cache (node_modules/.cache/zod-compiler, or a custom directory). Skips discovery + codegen across processes when nothing changed; entries self-validate against dependency content hashes
zodCompiler({
  include: ["src/schemas"],
  verbose: true,
});

Note: Vitest is detected automatically (via the VITEST env var), so tests compile and exercise the same validators that ship to production — including their performance. Pass apply: "build" if you want tests to use the plain Zod fallback instead.

Bun

zod-compiler is a build-time tool, so on Bun it compiles wherever your code passes through a build step; everywhere else schemas still run as plain Zod (correct, just not accelerated). The Bun plugin requires Bun ≥ 1.2.22.

Bundled code (a frontend, or a server you bun build --target=bun first) — register the plugin and every exported (and hoisted) schema compiles:

import zodCompiler from "zod-compiler/bun";

await Bun.build({
  entrypoints: ["./src/index.tsx"],
  outdir: "./dist",
  plugins: [zodCompiler()],
});

Code run straight from source (bun run src/server.ts) — a Bun.build plugin doesn't run here, so use the CLI to compile ahead of time and import the output:

bunx zod-compiler generate src/ -o src/compiled/   # add --watch during development

Schema Hoisting

Schemas defined inside functions are rebuilt on every call — a hidden cost in React components, request handlers, and helpers. With hoist (on by default), the plugin moves them to module scope:

// before
function getSchema() {
  return z.object({ name: z.string() }); // rebuilt per call
}

// after (build output)
const _zh_94b7f5c1 = z.object({ name: z.string() });
function getSchema() {
  return _zh_94b7f5c1; // built once per module
}

Hoisting is conservative: only expressions built purely from imported bindings and literals move. Anything referencing local variables, module-level bindings, this, or eagerly-evaluated globals (new Date(), Math.random()) stays where it is — though safe globals inside callbacks (refine((v) => Number.isFinite(v))) are fine, since callbacks run per parse regardless. Inline .parse(...) calls are peeled so evaluation stays at the call site (z.string().parse(x) → _zh_….parse(x)), names that are ever shadowed (function f(z) {...}) disqualify hoists referencing them, and identical schemas dedupe to a single binding.

Combinator chains on imported schemas also qualify: bases matching schemaNamePattern (default /ZodSchema$/) or chains containing an inline z.* reference (Base.extend({ a: z.string() })). Configure via hoist: { schemaNamePattern: /Shape$/ } (string and null accepted).

Hoisted schemas compile too (auto mode)

The most common shape this rescues is a schema that never leaves a function — a slonik query, a tRPC input, a handler-local validator. It is not exported, so export scanning alone would never see it:

import { pool, sql } from "./db.js";
import { z } from "zod";

const getUser = (id: number) => {
  return pool.one(
    sql.type(
      z.object({
        id: z.number(),
        name: z.string(),
      }),
    )`SELECT id, name FROM users WHERE id = ${id}`,
  );
};

In auto mode (the default), the build output is (verbatim, lightly trimmed):

import { __zcFin, __zcFinD, __zcIT, __zcMkv } from "virtual:zod-compiler/runtime";
const _zh_6c9cb1a3 = /* @__PURE__ */ (() => {
  function __fc_0(input) {
    return (
      typeof input === "object" &&
      input !== null &&
      !Array.isArray(input) &&
      Number.isFinite(input["id"]) &&
      typeof input["name"] === "string"
    );
  }
  function __sw_2(input) {
    var _e = [];
    /* error-collecting walk — runs only when .error is read */ return _e;
  }
  function safeParse__zh_6c9cb1a3(input) {
    if (__fc_0(input)) {
      return { success: true, data: input };
    }
    return __zcFinD(__sw_2, input);
  }
  return __zcMkv(
    safeParse__zh_6c9cb1a3,
    z.object({
      id: z.number(),
      name: z.string(),
    }),
    __fc_0,
  );
})();
import { pool, sql } from "./db.js";
import { z } from "zod";

const getUser = (id: number) => {
  return pool.one(sql.type(_zh_6c9cb1a3)`SELECT id, name FROM users WHERE id = ${id}`);
};

Reading it bottom-up:

  • The real Zod schema is still constructed (once, at module load) and is the object _zh_6c9cb1a3 resolves to — __zcMkv installs the compiled parse/safeParse/parseAsync/safeParseAsync as own properties on it and returns it. sql.type() receives a genuine Zod schema (identity, .shape, ._zod, Standard Schema all intact) whose safeParse happens to be compiled.
  • __fc_0 is the Fast Path: when slonik validates each row, a valid row costs one boolean chain — no per-node traversal, no allocations beyond the result object.
  • __sw_2 + __zcFinD are the failure path: an invalid row returns {success: false} immediately; the full error walk runs lazily only if .error is actually read.
  • The sql.type(...) call itself stays at the call site (it closes over id via the tagged template) — only its schema argument was hoisted and compiled.

Measured on this exact pattern: schema construction + validation drops from ~16,700ns to ~14ns per call — construction amortizes to module load, and per-row validation rides the Fast Path. With schemas: "explicit" the same file still gets the plain hoist (construction once instead of per call); the compiled IIFE requires auto mode (the default) because the schema is anonymous.

Bundle Size & Cross-File Dedup

Generated validators share a small runtime helper layer (__zcMkv validator wrapper, issue factories like __zcTS/__zcIT, and well-known regexes for email, uuid, cuid, ipv4, etc.).

On every supported bundler the plugin imports these helpers from a single plugin-provided runtime module — virtual:zod-compiler/runtime on Vite, Rollup, Rolldown, esbuild, Farm, and Bun, or the bare-specifier alias __zod-compiler-runtime__ on webpack and rspack (which reject the virtual: URI scheme) — so the bundler emits a single bundle-wide copy regardless of how many files reference them.

Transpile-only esbuild builds (no --bundle, e.g. astro-scripts build) never invoke the bundler's onResolve/onLoad hooks for already-transformed files, so the virtual: specifier survives verbatim into dist/ and Node.js rejects it at runtime with ERR_UNSUPPORTED_ESM_URL_SCHEME. Set codegenMode: "inline" to emit helpers directly into each file instead:

import zodCompiler from "zod-compiler/esbuild";
export default [zodCompiler({ schemas: "explicit", codegenMode: "inline" })];

The result: a 5-file project with 10 schemas all using z.email() and z.uuid() produces a bundle where each shared regex appears exactly once. Set output: "bag" to additionally drop the original Zod schema reference when you don't need instanceof / .shape access on the compiled output.

Structural dedup within a file. Beyond the shared runtime layer, schemas in the same file that contain a structurally identical sub-tree — a reused Address, a Money pair, an exported schema also embedded in another — emit that shape's error-collecting walk once as a shared function and call it from every occurrence. Only the cold error path is shared (it's 60–80% of the generated bytes); the zero-allocation fast path stays fully inlined, so valid input runs exactly as fast as before. On a realistic schema set where User/Company/Order/Invoice reuse Address/Money/Contact, generated output drops ~50% raw / ~34% gzipped with no change to validation behavior.

Compact Output (output: "compact")

Structural dedup only helps when shapes repeat. For a large app of mostly distinct schemas it can't fire, and the per-schema error-collecting walk — 64–77% of the generated bytes — is emitted in full for every schema. But that walk exists only to reproduce Zod's issues on failure, and in "schema" mode the original Zod schema is already in your bundle. output: "compact" exploits this: it compiles the fast path as usual and, on a fast-check failure, delegates the cold error path to the retained schema's own safeParse instead of emitting a compiled slow walk.

import zodCompiler from "zod-compiler/vite";

export default defineConfig({
  plugins: [zodCompiler({ output: "compact" })],
});

On a 50-schema set of distinct shapes (where dedup can't help), generated output drops ~73% raw / ~71% gzipped:

ModeRawGzip
schema169,59916,645
compact45,7354,789

The gzip win is far larger than collapsing duplicated code (which gzip already compresses well) because the slow walk is removed, not re-encoded.

What it costs — and doesn't:

  • Hot path unchanged. parse/safeParse of valid input and the .is() guard run the exact same compiled fast check as "schema" mode. Identity is preserved (.shape, .meta(), z.toJSONSchema(), instanceof, tRPC/Hono all keep working).
  • Errors are Zod's own. A failed safeParse reports byte-identical issues to Zod — there is no second validation engine to drift, so correctness is guaranteed by construction.
  • Cold error reporting runs Zod. Reading .error (or .parse() throwing) on invalid input runs Zod's full parse — slower than the compiled slow walk, but it's the cold path. The delegation is lazy: safeParse(x).success and .is(x) never invoke Zod (the fast check alone decides), so the common validation-failure checks stay fast.
  • Mutation schemas keep the compiled path. Schemas that transform their input (default / catch / coerce / transform) are compiled exactly as in "schema" mode — only pure validators delegate.

Use it when bundle size dominates (large schema counts, edge/serverless cold starts, memory at scale) and you can afford a slower error path. It requires the Zod schema, so it's mutually exclusive with output: "bag" (which drops it). The CLI exposes it as --emit compact.

Auto Mode: Side Effects Warning

In auto mode (the default), the plugin executes files to inspect their exports. A static pre-filter skips files whose exports provably can't be schemas without executing them — but if a file has schema-shaped exports AND side effects (starts a server, connects to a database), those side effects run at build time.

Fix: Use include to limit which files are scanned:

zodCompiler({
  include: ["src/schemas", "src/validators"],
});

Environment validation that calls process.exit

A common pattern is an env.ts that validates process.env and calls process.exit(1) when required secrets are missing — schema files often import it transitively. In a CI build those secrets are intentionally absent, so executing the file at build time would otherwise terminate the bundler.

zod-compiler guards against this. While it executes a module for discovery it:

  1. Sets process.env.ZOD_COMPILER so cooperating modules can skip validation.
  2. Intercepts process.exit — an unguarded exit becomes a normal load failure, so the build does not crash. The affected files fall back to runtime Zod and a one-time warning names the optimization that was skipped.

To keep those schemas compiled, guard the exit on the marker:

// env.ts
if (!process.env.ZOD_COMPILER) {
  const result = envSchema.safeParse(process.env);
  if (!result.success) {
    console.error("Missing required environment variables:", result.error.format());
    process.exit(1);
  }
}

If you use @t3-oss/env-*, pass skipValidation: !!process.env.ZOD_COMPILER.

(Only synchronous exits during module evaluation are intercepted — an exit deferred to a setTimeout or later event still exits.)

schemas: "auto" vs "explicit"

"auto" (default)"explicit" + compile()
Source code changesNoneWrap each schema
zod-compiler import neededNoYes
What gets compiledAll exported Zod schemasOnly wrapped schemas
Build-time file executionZod-importing files that may export schemas (pre-filtered)Files with import ... from "zod-compiler"
Best forNew projects, framework integrationGradual adoption, selective optimization

Large projects and CI

Discovery executes each schema file — and transitively its first-party import graph — inside the bundler's single-threaded process. In a repository where schema files pull in thousands of modules, the first cold run is the expensive part: subsequent runs hit the persistent cache and skip discovery entirely. On saturated CI hosts a cold discovery of a huge graph can stall the bundler's event loop long enough to trip test timeouts (the plugin warns when a single file's discovery exceeds 5s). Three levers, in order of impact:

1. Persist the cache across CI runs. The cache directory is small (dependency snapshots are content-addressed and shared between entries) and entries self-validate against dependency content hashes — restoring a stale cache can only cause recompiles, never stale output:

# GitHub Actions
- uses: actions/cache@v4
  with:
    path: node_modules/.cache/zod-compiler
    key: zod-compiler-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: zod-compiler-${{ runner.os }}-

2. Scope what gets discovered. include limits discovery to your schema directories. If test startup latency matters more than test-time validator performance, run hoist-only in Vitest and compile only real builds:

// vitest.config.ts — hoisting still applies; validation uses plain Zod
zodCompiler({ schemas: "explicit" });

// vite.config.ts (build)
zodCompiler({ include: ["src/schemas"] });

3. Measure before tuning. ZOD_COMPILER_TIMING=1 prints per-phase wall time (hoist / static-filter / discover / compile) on exit, so you can see whether discovery or codegen dominates and which files pay it.

Framework Examples

tRPC

// src/schemas.ts
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.email(),
  age: z.number().int().min(0).max(150),
});

// src/router.ts
import { CreateUserSchema } from "./schemas";

export const appRouter = t.router({
  createUser: t.procedure.input(CreateUserSchema).mutation(({ input }) => createUser(input)),
});

In auto mode (the default), CreateUserSchema is compiled at build time. The tRPC router uses the optimized version automatically. No .input(compile(CreateUserSchema)) needed.

Hono

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { UserSchema } from "./schemas";

const app = new Hono();

app.post("/users", zValidator("json", UserSchema), (c) => {
  const user = c.req.valid("json");
  return c.json(user);
});

React Hook Form

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserSchema } from "./schemas";

function UserForm() {
  const form = useForm({
    resolver: zodResolver(UserSchema),
  });
  // ...
}

Any Standard Schema Consumer

Compiled schemas are the original Zod schema objects with optimized parse methods installed, so they still implement Standard Schema. Any library that accepts Standard Schema validators works automatically.

Schema Diagnostics

Analyze your schemas before compiling — check coverage, Fast Path eligibility, and get actionable hints:

npx zod-compiler check src/schemas.ts

Output:

src/schemas.ts

  CreateUserSchema — 100% compiled (4/4 nodes) | Fast Path: eligible
    └─ ✓ object
       ├─ ✓ string .name
       ├─ ✓ string .email
       ├─ ✓ number .age
       └─ ✓ enum .role

  OrderSchema — 67% compiled (2/3 nodes) | Fast Path: ineligible (fallback (transform))
    └─ ✓ object
       ├─ ✓ string .id
       └─ ✓ object .metadata
          ├─ ✓ string .metadata.region
          └─ ✗ fallback .metadata.audit (transform)
                hint: Extract transform into a separate post-processing step

    Fallbacks:
      ✗ .metadata.audit — transform
        Extract transform into a separate post-processing step

CI Integration

# JSON output
npx zod-compiler check src/schemas.ts --json

# Fail if any schema below 80% coverage
npx zod-compiler check src/schemas.ts --json --fail-under 80
FlagDescription
--jsonStructured JSON output
--fail-under <pct>Exit code 1 if coverage below threshold
--no-colorDisable colored output

What Gets Compiled

Fully Compiled (2-75x faster)

string, number, bigint, boolean, null, undefined, any, unknown, literal, enum, stringbool, date, file, object, strictObject / .strict(), looseObject, array, tuple, record, set, map, union, discriminatedUnion, intersection, pipe (non-transform), optional, nullable, readonly, default, catch, coerce, templateLiteral, symbol, void, nan, never, lazy (recursive — self-, mutual, and nested), transform / refine (zero-capture — see below)

All standard Zod checks are supported: min, max, length, email, url, uuid, regex, int, positive, negative, multipleOf, int32, uint32, float32, float64, includes, startsWith, endsWith, and more.

Falls Back to Zod (Still Works, Not Faster)

These contain JavaScript callbacks that cannot be reproduced in generated code:

TypeWhyAlternative
transform / refine with capturesCallback captures outer variables (or is async / takes ctx)Use zero-capture callbacks or built-in checks
superRefineCallback needs ctx for issue collectionUse refine or built-in checks
customArbitrary validation logic—
preprocessInput preprocessing functionUse z.coerce when possible
lazy (unresolvable inner)Getter throws / inner type can't be resolved at compile timeEnsure the lazy getter returns a static schema
.catchall(schema)Unknown keys validated against a value schemastrictObject and looseObject both compile

Zero-capture effects compile: a transform/refine callback that takes a single argument and references only its own parameters, locals, and safe globals (Math, Number, JSON, …) is extracted via fn.toString() and inlined into the generated validator. z.string().transform((s) => s.trim()) compiles; z.string().transform((s) => s + suffix) falls back (it captures suffix).

Partial fallback: If an object has 10 properties and 1 uses transform, the other 9 are still compiled. Only the transform property falls back to Zod.

Recursive schemas compile — whether directly self-recursive (z.lazy(() => Self)), mutually recursive (A ↔ B), or nested as a field of a larger root (a recursive Comment inside z.object({ thread, root: Comment })). Each distinct recursive shape is hosted once as a dedicated validator and reached by reference, so the whole structure stays on the fast path instead of delegating to Zod — a recursive type nested in an API envelope runs 12–33x faster than Zod (see the benchmark table). A lazy schema only falls back when its getter can't be resolved at compile time.

Tip: Run npx zod-compiler check to see exactly which parts of your schemas are compiled and which fall back.

Behavioral Differences from Zod

Compiled validators match Zod on accept/reject decisions, output data for the known shape, and error messages — including issue ordering for multi-failure inputs. A few observable behaviors differ by design, all stemming from the zero-allocation fast path: a successful parse returns the input value itself rather than rebuilding it.

BehaviorZodzod-compiler
Unknown keys on a default z.object()Stripped from the outputKept by default — returned by reference; opt in with stripUnknownKeys
Record key iterationAll own keys (Reflect.ownKeys)Own enumerable string keys only — symbol and non-enumerable keys are ignored
Array / object output identityA fresh valueThe input value, returned by reference

What this means in practice:

  • Unknown keys are not stripped by default. z.object({ a: z.string() }).parse({ a: "x", b: 1 }) returns { a: "x" } under Zod but { a: "x", b: 1 } compiled. Three ways to get stripping behavior, in order of preference:

    • Enable the stripUnknownKeys build option (or the --strip-unknown-keys CLI flag). Genuine z.object() schemas then rebuild a fresh object with only the declared keys — exactly matching Zod's default strip, including nested objects, array elements, .pick()/.partial()/.extend() results, and discriminated-union options. This is the right choice if you forward parsed request bodies to an ORM and need protection against mass-assignment / overposting. (Cost: stripped objects are rebuilt on every successful parse, so they no longer take the by-reference fast path.)
    • Use z.strictObject() if you'd rather reject unknown keys outright (changes the API contract — extras become errors).
    • Use z.looseObject() to make the default keep-extras behavior explicit at the schema level.

    All three compile fully, and validation of the declared keys is identical in every case.

    Performance of stripUnknownKeys (pnpm benchmark strip-unknown-keys): stripping rebuilds the object, so it gives up the by-reference fast path — but still beats Zod, which also rebuilds. Representative throughput:

    Schema (input)Zod (strips)compiler (keep, default)compiler (strip)strip vs keepstrip vs Zod
    medium object, 7 keys (clean)2.3M10.7M7.7M0.7x3.3x
    wide object, 20 keys (clean)3.5M15.7M4.7M0.3x1.3x
    nested API response (clean)152K7.8M1.3M0.17x8.7x

    The cost scales with key count and nesting depth (every declared key is copied into the fresh object); it's independent of whether unknown keys are actually present. Keep stripping off where you don't need sanitization (forwarding to an ORM, etc.) and on where you do.

  • Records skip symbol / non-enumerable keys. z.record(z.string(), …) validates (and rejects) a symbol-keyed or non-enumerable-keyed entry under Zod; the compiled record never visits it. Plain string-keyed records — the common case — are unaffected.

Matching Zod on these would mean allocating a fresh object (or a Reflect.ownKeys array) on every successful parse — the exact cost the fast path exists to avoid.

Benchmark

5-way comparison: Zod v3 vs Zod v4 vs zod-compiler vs Typia vs AJV

ScenarioZod v3Zod v4zod-compilerTypiaAJVvs Zod v4
simple string13.3M14.4M16.2M17.7M17.8M1.1x
string (min/max)13.0M8.0M17.2M18.1M16.3M2.2x
number (int+positive)11.5M7.8M15.7M16.4M16.7M2.0x
enum11.3M12.3M16.9M17.2M17.6M1.4x
bigint (min/max)11.8M7.9M15.7M——2.0x
tuple [string, int, bool]6.0M6.5M17.0M16.2M16.5M2.6x
record<string, number>3.3M2.8M8.5M11.5M15.1M3.0x
set<string> (5 items)3.7M2.3M15.2M——6.7x
set<string> (20 items)1.3M695K12.1M——17x
map<string, number> (5 entries)2.1M1.4M13.1M——9.6x
map<string, number> (20 entries)652K361K8.6M——24x
pipe (non-transform)8.8M5.9M16.1M——2.7x
discriminatedUnion (3 variants)3.3M4.0M16.1M15.8M8.0M4.0x
discriminatedUnion (8 variants, rotating)2.7M3.5M9.6M——2.7x
plain union of 8 tagged objects (auto-discrim.)368K655K8.6M——13x
strict object (DB row)1.8M3.2M7.3M——2.3x
medium object (valid)2.0M2.4M10.3M11.4M7.7M4.3x
medium object (invalid)536K80K15.5M2.9M7.9M194x
large object (10 items)123K174K8.0M5.9M1.3M46x
large object (100 items)13K19K1.4M1.3M127K73x
recursive tree (7 nodes)547K2.0M11.8M11.7M4.7M5.8x
recursive tree (121 nodes)32K142K2.3M1.9M356K16x
nested recursion (7 nodes)389K1.0M11.7M10.8M3.0M12x
nested recursion (121 nodes)24K61K2.0M1.6M220K33x
deeply nested object (243 leaves)11K19K1.2M1.0M122K64x
event log (combined)382K618K5.8M——9.4x
object with transform (zero-capture)1.2M1.9M6.1M——3.3x
array 10 × transform (zero-capture)129K220K3.4M——15x
array 50 × transform (zero-capture)26K44K821K——19x
object with captured transform (partial fallback)1.4M6.4M6.2M——1.0x

ops/s, higher is better. "—" = not supported by the library. Measured with vitest bench on Apple M4 Max (zod 4.3.6, zod v3 3.23.8, typia 12, ajv 8).

Performance scales with schema complexity. Nested objects and arrays see the biggest gains because zod-compiler eliminates per-node traversal overhead. Deeply nested schemas (the 243-leaf dashboard row) stay fast because oversized fast-check functions are split into smaller boolean helpers, each kept within V8's optimizing-compiler budget. discriminatedUnion uses O(1) switch dispatch instead of Zod's sequential trial, and each case validates only its variant's distinctive fields — the object type-guard and the discriminator are checked once before dispatch, never re-checked inside the matched case (a redundancy the engine only elides on unions small enough to inline, so large unions get a measured ~1.5x on the fast check). A plain z.union of objects that all pin a shared key to disjoint literals is auto-detected and lowered to the same switch dispatch — so an untagged union written without discriminatedUnion still validates in O(1) (13x faster than Zod here), as long as it has enough options to outweigh the switch's setup cost; below that it keeps the fully-inlined ||-chain. The invalid-input row is large because failed safeParse defers error materialization until .error is read. Zero-capture transform/refine callbacks are compiled (3-19x); schemas with captured callbacks fall back per-field and roughly match Zod.

parse() (throwing API) rides a zero-allocation fast path: medium object 2.3M → 9.7M ops/s (4.1x), large object (100 items) 17K → 1.4M ops/s (79x).

pnpm benchmark   # run locally

Performance Architecture

For eligible schemas, zod-compiler generates a two-phase validator:

  1. Fast Path — A single && expression chain that validates the entire input with zero allocations. Valid input returns immediately.
  2. Slow Path — Error-collecting validation that only runs when the Fast Path fails.

Additional optimizations: check ordering (cheap checks first), pre-compiled regex, Set-based enum lookups, small enum inlining (=== for up to 5 values), discriminated-union cases that skip the now-redundant object-guard and discriminator re-check after switch dispatch, and auto-discrimination of plain z.unions of tagged objects into the same switch dispatch.

Run npx zod-compiler check --json to see which schemas qualify for Fast Path.

Development

pnpm install
pnpm test
pnpm benchmark
pnpm lint

Acknowledgements

zod-compiler started as a fork of zod-aot by @wakita181009.