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(),
})
);
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 })],
});
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(),
});
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)
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;
}
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
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;
})();
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)
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"],
});
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")
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()
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
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
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
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"
}]
}
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)),
});
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");
});
React Hook Form: Resolver schemas auto-compiled.
const { register } = useForm({
resolver: zodResolver(CreateUserSchema), // AOT-compiled
});
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
// vite.config.ts — that's the entire setup
import zodAot from "zod-aot/vite";
export default defineConfig({
plugins: [zodAot({ autoDiscover: true })],
});
Your existing Zod schemas are now AOT-compiled. No code changes.
Also works with webpack, esbuild, Rollup, Rolldown, rspack, and Bun via unplugin.
- GitHub: github.com/wakita181009/zod-aot
- npm: zod-aot
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)