DEV Community

Cover image for Zod vs Typia vs AJV — I Built a Build Plugin That Makes Zod 60x Faster With Zero Code Changes
Tetsuya Wakita
Tetsuya Wakita

Posted on

Zod vs Typia vs AJV — I Built a Build Plugin That Makes Zod 60x Faster With Zero Code Changes

When I first released Zod AOT, it required wrapping every schema with compile():

import { compile } from "zod-aot";

const UserSchema = compile(
  z.object({
    name: z.string().min(1),
    email: z.email(),
  })
);
Enter fullscreen mode Exit fullscreen mode

The #1 feedback was: "I don't want to change my code."

Fair. So I removed that requirement. Zod AOT now has an autoDiscover mode — a Vite plugin that finds your Zod schemas at build time, compiles them into optimized validators, and replaces them in-place. Your schema files stay pure Zod. No imports from zod-aot. No wrappers. Just this:

// vite.config.ts
import zodAot from "zod-aot/vite";

export default defineConfig({
  plugins: [zodAot({ autoDiscover: true })],
});
Enter fullscreen mode Exit fullscreen mode

That's it. Every exported Zod schema in your project gets AOT-compiled at build time.

I also added Typia and AJV to the benchmarks, built a two-phase "Fast Path" validator, and shipped a diagnostic CLI. Here's what changed.

autoDiscover: How It Works

The challenge is detecting Zod schemas without any marker in the source code. There's no compile() call, no special import — just plain Zod:

// src/schemas.ts — plain Zod, no Zod AOT import
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(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(["admin", "editor", "viewer"]).optional(),
});
Enter fullscreen mode Exit fullscreen mode

The plugin detects and compiles these in four steps:

Step 1: Regex Pre-filter

Before doing any work, the plugin checks if the file contains a runtime Zod import:

import { z } from "zod"     (runtime  schema definitions)
import type { z } from "zod"    (type-only  skip)
Enter fullscreen mode Exit fullscreen mode

Files without a runtime Zod import are skipped entirely. This keeps build times fast — most files in your project aren't schema files.

Step 2: Execute and Inspect Exports

The plugin loads the source file at build time using jiti and inspects each export:

function isZodSchema(value: unknown): boolean {
  if (typeof value !== "object" || value === null || !("_zod" in value))
    return false;
  const zod = (value as Record<string, unknown>)["_zod"];
  return typeof zod === "object" && zod !== null && "def" in zod;
}
Enter fullscreen mode Exit fullscreen mode

Every Zod schema has a _zod.def structure — this is the internal definition that describes the schema's type, checks, and children. If an export has this marker, it's a Zod schema.

Step 3: Compile to Optimized Validators

Each discovered schema goes through the Zod AOT compilation pipeline:

Zod Schema → Extract IR → Generate Code → Wrap in IIFE
Enter fullscreen mode Exit fullscreen mode

The pipeline walks the schema tree, extracts an intermediate representation, and generates flat, inlined validation code — the same process as before, but triggered automatically.

Step 4: AST-Based Source Replacement

The plugin uses acorn to parse expression boundaries in the source code:

// Before (source)
export const CreateUserSchema = z.object({ ... });

// After (build output)
export const CreateUserSchema = /* @__PURE__ */ (() => {
  // ... generated validation code ...
  var __w = Object.create(originalSchema);
  __w.parse = function(input) { /* optimized */ };
  __w.safeParse = __validate;
  __w.schema = originalSchema;
  return __w;
})();
Enter fullscreen mode Exit fullscreen mode

The Object.create(originalSchema) is key — it preserves the full Zod API. Your compiled schema still has .shape, .keyof(), .pick(), .merge(), and everything else. Framework code that inspects schema metadata (like tRPC or React Hook Form resolvers) continues to work.

Build Output

With verbose: true, you see what happened:

[zod-aot] Auto-discovering: src/schemas.ts (4 Zod exports found)
[zod-aot]   ✓ CreateUserSchema
[zod-aot]   ✓ UpdateUserSchema
[zod-aot]   ✓ ListUsersSchema
[zod-aot]   ✓ UserIdSchema
[zod-aot] Build summary: 4/4 schemas optimized across 1 file(s)
Enter fullscreen mode Exit fullscreen mode

Scoping

Not every file should be auto-discovered — you probably don't want to execute test fixtures or files with side effects at build time. Use include and exclude:

zodAot({
  autoDiscover: true,
  include: ["src/schemas"],
  exclude: ["test", "mock"],
});
Enter fullscreen mode Exit fullscreen mode

Two-Phase Validation: The Fast Path

The prior version of Zod AOT generated error-collecting validators — they always created an issues array and tracked every validation failure. That's necessary for error reporting, but it's overhead when the input is valid.

In production, most inputs are valid. So I added a Fast Path.

Phase 1: Boolean Expression Chain

For valid inputs, the entire schema is validated with a single boolean expression:

// Generated Fast Path for CreateUserSchema
typeof input==="object"&&input!==null&&!Array.isArray(input)&&
typeof input.name==="string"&&input.name.length>=1&&input.name.length<=100&&
typeof input.email==="string"&&__re0.test(input.email)&&
typeof input.age==="number"&&!Number.isNaN(input.age)&&
  Number.isSafeInteger(input.age)&&input.age>=0&&input.age<=150&&
(input.role==="admin"||input.role==="editor"||input.role==="viewer")
Enter fullscreen mode Exit fullscreen mode

If this evaluates to true, the function returns { success: true, data: input } immediately.

Zero allocations. No issues array. No error objects. No path arrays. Just a boolean chain that short-circuits on the first failure.

Regex patterns and enum Sets are pre-compiled in a preamble:

var __re0 = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
// Small enums (≤3 values) use inline === checks instead of Set.has()
Enter fullscreen mode Exit fullscreen mode

Phase 2: Error-Collecting Path (Slow Path)

If the Fast Path fails — meaning the input has at least one issue — the full error-collecting validator runs. This creates the standard { success: false, error: { issues: [...] } } response with codes, paths, and messages.

The key insight: the Slow Path only runs when it's actually needed. For hot API endpoints where 95%+ of requests pass validation, the Fast Path is all that executes.

Fast Path Eligibility

Not every schema qualifies for a Fast Path. Schemas with .transform(), .refine(), .default(), .coerce(), or .catch() are ineligible because they need to modify the input or run opaque functions. The check command (see below) tells you which schemas qualify.

The 5-Way Benchmark

I added Typia and AJV to the benchmark suite. All five validators parse the same data with equivalent schemas:

  • Zod v3: Interpreter model, walks schema tree on every call
  • Zod v4: New architecture with JIT object compilation via new Function()
  • Zod AOT: Build-time compilation with two-phase validation
  • Typia: Compile-time code generation via TypeScript transformer
  • AJV: JSON Schema validator with ahead-of-time compilation

Environment: Node.js v22, Apple M-series, Vitest bench, 1M+ iterations with warmup.

Full Results

Schema Zod v3 Zod v4 Zod AOT Typia AJV
String min/max 8.3M 5.6M 10.6M 10.5M 8.8M
Enum 7.6M 9.3M 10.2M 10.8M 10.7M
Medium object (valid) 1.3M 1.7M 5.2M 7.0M 4.8M
Medium object (invalid) 365K 63K 471K 2.1M 4.7M
Large object — 100 items 8.6K 11.6K 627K 808K 89K
Discriminated union (3) 2.3M 3.0M 10.2M 10.6M 5.4M
Recursive tree — 121 nodes 23K 103K 733K 1.4M 251K
Set (20 items) 980K 461K 7.6M
Map (20 entries) 479K 235K 5.1M
Event log (mixed types) 263K 473K 4.5M
Partial fallback — 50 items 18K 30K 238K

ops/sec — higher is better. "—" = type not supported by the library.

What the numbers tell us:

Primitives — All AOT approaches converge around ~10.5M ops/sec. This is the V8 ceiling for simple type checks. No meaningful difference between Zod AOT, Typia, and AJV.

Objects — Typia leads (1.3x over Zod AOT on medium, 1.2x on large). But the gap narrows as objects grow — the Fast Path's single boolean chain scales well. AJV collapses on large objects (9x slower than Typia at 100 items). Zod v4 is 3-54x slower than both.

Invalid inputs — AJV dominates the error path (4.7M ops/s) with minimal error construction. Zod v4 is slowest at 63K due to rich structured errors. In practice, most production inputs are valid — the Fast Path handles those.

Collections (Set/Map) — Zod AOT's exclusive territory. Typia doesn't validate Set or Map contents. AJV doesn't support JS-native types. Zod AOT is 16-22x faster than Zod v4 here.

Recursive — Typia leads 1.9x over Zod AOT on deep trees. Zod AOT currently uses z.lazy() fallback for recursive references — improving this is on the roadmap.

Partial fallback — Schemas with .transform() can't be fully AOT-compiled, but Zod AOT compiles everything it can and delegates the rest to Zod. Even partial compilation delivers 2.6-8x over Zod v4.

The Scoreboard

Category Winner Runner-up
Primitives Tie (all AOT ≈ 10.5M)
Objects (valid) Typia Zod AOT (within 1.2x)
Objects (invalid) AJV Typia
Collections (Set/Map) Zod AOT (only competitor)
Discriminated unions Tie (Typia ≈ Zod AOT)
Recursive Typia Zod AOT (1.9x gap)
Partial compilation Zod AOT (only competitor)

Zod AOT sits in a unique position: near-Typia performance with full Zod API compatibility. You don't need type-level tags, a different schema DSL, or JSON Schema. You keep z.object(), z.email(), .transform(), .refine() — and the build plugin handles the rest.

The check Command: Know Your Coverage

Not sure what percentage of your schemas will be compiled? Run check:

npx zod-aot check src/schemas.ts
Enter fullscreen mode Exit fullscreen mode
validateUser — 100% compiled (8/8 nodes) | Fast Path: eligible
  └─ ✓ object
     ├─ ✓ string .name
     ├─ ✓ string .email
     ├─ ✓ number .age
     ├─ ✓ enum .role
     ├─ ✓ boolean .isActive
     ├─ ✓ array .tags
     │  └─ ✓ string .tags[]
     └─ ✓ nullable .bio
        └─ ✓ string .bio

validateOrder — 80% compiled (4/5 nodes) | Fast Path: ineligible
  └─ ✓ object
     ├─ ✓ string .orderId
     ├─ ✓ number .amount
     ├─ ✓ enum .currency
     └─ ✗ fallback .slug (transform)
           hint: Extract transform into a separate post-processing step
Enter fullscreen mode Exit fullscreen mode

Each schema gets a tree view showing:

  • Which nodes are fully compiled (✓) vs falling back to Zod (✗)
  • Compilation coverage percentage
  • Fast Path eligibility
  • Actionable hints for improving coverage

CI Integration

# Fail if compilation coverage drops below 80%
npx zod-aot check src/schemas/ --fail-under 80 --json
Enter fullscreen mode Exit fullscreen mode

The --json flag outputs structured data for CI pipelines:

{
  "exportName": "validateOrder",
  "coverage": { "total": 5, "compilable": 4, "percent": 80 },
  "fastPath": { "eligible": false, "blocker": "fallback (transform)" },
  "fallbacks": [{
    "reason": "transform",
    "path": ".slug",
    "hint": "Extract transform into a separate post-processing step"
  }]
}
Enter fullscreen mode Exit fullscreen mode

Framework Integration

autoDiscover works at the build layer — framework code doesn't know or care:

tRPC: Schemas auto-compiled, router unchanged.

// router.ts — no changes needed
const appRouter = router({
  createUser: publicProcedure
    .input(CreateUserSchema)  // ← this is now AOT-compiled
    .mutation(({ input }) => db.users.create(input)),
});
Enter fullscreen mode Exit fullscreen mode

Hono: Middleware validation auto-optimized.

app.post("/users", zValidator("json", CreateUserSchema), (c) => {
  // CreateUserSchema is AOT-compiled at build time
  const data = c.req.valid("json");
});
Enter fullscreen mode Exit fullscreen mode

React Hook Form: Resolver schemas auto-compiled.

const { register } = useForm({
  resolver: zodResolver(CreateUserSchema), // AOT-compiled
});
Enter fullscreen mode Exit fullscreen mode

The Object.create() wrapper ensures that framework code reading .shape, .keyof(), or other Zod metadata still works — the compiled schema inherits from the original.

What I Learned from the Competition

Typia is the performance king for objects and recursive structures. Its compile-time TypeScript transformer generates monolithic validation functions with zero runtime overhead. But:

  • It requires type-level tags (tags.MinLength<3>) instead of method chaining — a different mental model
  • It doesn't validate Set or Map contents
  • No bigint support
  • No .transform() or .refine() — it's a validator, not a parser

AJV has the best error-path performance and the broadest ecosystem (JSON Schema is a standard). But:

  • JSON Schema doesn't support JavaScript-native types (Set, Map)
  • Verbose schema definitions — no chaining, no composition
  • No transforms or pipes — declarative validation only

Zod AOT fills the gap: Zod's composable API, near-Typia performance, full JavaScript type support, and partial compilation for schemas that mix validation with transformation.

Get Started

npm install zod-aot zod@^4
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts — that's the entire setup
import zodAot from "zod-aot/vite";

export default defineConfig({
  plugins: [zodAot({ autoDiscover: true })],
});
Enter fullscreen mode Exit fullscreen mode

Your existing Zod schemas are now AOT-compiled. No code changes.

Also works with webpack, esbuild, Rollup, Rolldown, rspack, and Bun via unplugin.

Methodology

All benchmarks use safeParse() on pre-created schema instances, measured with Vitest bench (1M+ iterations, with warmup). Numbers are steady-state throughput — JIT is already compiled after the first call. Typia uses createValidate<T>(). AJV uses ajv.compile(schema). Invalid-input benchmarks use all-fields-invalid payloads to stress the error path.

  • Environment: Node.js v22, Apple M-series
  • Libraries: Zod v3.24, Zod v4, Zod AOT 0.14, Typia 12, AJV 8
  • Raw data: benchmarks/ in the zod-aot repo

Top comments (0)