zod-compiler 1.22.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +154 -588
  2. package/dist/cli/commands/generate.d.ts +0 -2
  3. package/dist/cli/commands/generate.d.ts.map +1 -1
  4. package/dist/cli/commands/generate.js +0 -2
  5. package/dist/cli/commands/generate.js.map +1 -1
  6. package/dist/cli/commands/watch.d.ts +0 -1
  7. package/dist/cli/commands/watch.d.ts.map +1 -1
  8. package/dist/cli/commands/watch.js +0 -2
  9. package/dist/cli/commands/watch.js.map +1 -1
  10. package/dist/cli/index.js +0 -7
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/core/codegen/build-path.d.ts +48 -0
  13. package/dist/core/codegen/build-path.d.ts.map +1 -0
  14. package/dist/core/codegen/build-path.js +642 -0
  15. package/dist/core/codegen/build-path.js.map +1 -0
  16. package/dist/core/codegen/context.d.ts +106 -9
  17. package/dist/core/codegen/context.d.ts.map +1 -1
  18. package/dist/core/codegen/context.js +89 -39
  19. package/dist/core/codegen/context.js.map +1 -1
  20. package/dist/core/codegen/dedupe.d.ts +7 -0
  21. package/dist/core/codegen/dedupe.d.ts.map +1 -1
  22. package/dist/core/codegen/dedupe.js +7 -0
  23. package/dist/core/codegen/dedupe.js.map +1 -1
  24. package/dist/core/codegen/fast-path.d.ts.map +1 -1
  25. package/dist/core/codegen/fast-path.js +11 -5
  26. package/dist/core/codegen/fast-path.js.map +1 -1
  27. package/dist/core/codegen/index.d.ts.map +1 -1
  28. package/dist/core/codegen/index.js +95 -5
  29. package/dist/core/codegen/index.js.map +1 -1
  30. package/dist/core/codegen/issue-decls.d.ts +40 -0
  31. package/dist/core/codegen/issue-decls.d.ts.map +1 -1
  32. package/dist/core/codegen/issue-decls.js +70 -0
  33. package/dist/core/codegen/issue-decls.js.map +1 -1
  34. package/dist/core/codegen/regex-unroll.d.ts +36 -0
  35. package/dist/core/codegen/regex-unroll.d.ts.map +1 -0
  36. package/dist/core/codegen/regex-unroll.js +231 -0
  37. package/dist/core/codegen/regex-unroll.js.map +1 -0
  38. package/dist/core/codegen/schemas/array.d.ts.map +1 -1
  39. package/dist/core/codegen/schemas/array.js +22 -5
  40. package/dist/core/codegen/schemas/array.js.map +1 -1
  41. package/dist/core/codegen/schemas/default.d.ts +15 -0
  42. package/dist/core/codegen/schemas/default.d.ts.map +1 -1
  43. package/dist/core/codegen/schemas/default.js +26 -5
  44. package/dist/core/codegen/schemas/default.js.map +1 -1
  45. package/dist/core/codegen/schemas/discriminated-union.d.ts.map +1 -1
  46. package/dist/core/codegen/schemas/discriminated-union.js +3 -3
  47. package/dist/core/codegen/schemas/discriminated-union.js.map +1 -1
  48. package/dist/core/codegen/schemas/map.d.ts +17 -0
  49. package/dist/core/codegen/schemas/map.d.ts.map +1 -1
  50. package/dist/core/codegen/schemas/map.js +62 -7
  51. package/dist/core/codegen/schemas/map.js.map +1 -1
  52. package/dist/core/codegen/schemas/nullable.d.ts.map +1 -1
  53. package/dist/core/codegen/schemas/nullable.js +2 -4
  54. package/dist/core/codegen/schemas/nullable.js.map +1 -1
  55. package/dist/core/codegen/schemas/number.d.ts.map +1 -1
  56. package/dist/core/codegen/schemas/number.js +25 -0
  57. package/dist/core/codegen/schemas/number.js.map +1 -1
  58. package/dist/core/codegen/schemas/object.d.ts.map +1 -1
  59. package/dist/core/codegen/schemas/object.js +27 -18
  60. package/dist/core/codegen/schemas/object.js.map +1 -1
  61. package/dist/core/codegen/schemas/optional.d.ts +10 -0
  62. package/dist/core/codegen/schemas/optional.d.ts.map +1 -1
  63. package/dist/core/codegen/schemas/optional.js +4 -6
  64. package/dist/core/codegen/schemas/optional.js.map +1 -1
  65. package/dist/core/codegen/schemas/record.d.ts.map +1 -1
  66. package/dist/core/codegen/schemas/record.js +11 -5
  67. package/dist/core/codegen/schemas/record.js.map +1 -1
  68. package/dist/core/codegen/schemas/set.d.ts.map +1 -1
  69. package/dist/core/codegen/schemas/set.js +11 -7
  70. package/dist/core/codegen/schemas/set.js.map +1 -1
  71. package/dist/core/codegen/schemas/string.d.ts +13 -2
  72. package/dist/core/codegen/schemas/string.d.ts.map +1 -1
  73. package/dist/core/codegen/schemas/string.js +68 -56
  74. package/dist/core/codegen/schemas/string.js.map +1 -1
  75. package/dist/core/codegen/schemas/template-literal.d.ts.map +1 -1
  76. package/dist/core/codegen/schemas/template-literal.js +9 -1
  77. package/dist/core/codegen/schemas/template-literal.js.map +1 -1
  78. package/dist/core/codegen/schemas/tuple.d.ts.map +1 -1
  79. package/dist/core/codegen/schemas/tuple.js +4 -3
  80. package/dist/core/codegen/schemas/tuple.js.map +1 -1
  81. package/dist/core/codegen/schemas/union.d.ts.map +1 -1
  82. package/dist/core/codegen/schemas/union.js +2 -1
  83. package/dist/core/codegen/schemas/union.js.map +1 -1
  84. package/dist/core/codegen/well-known-regex.d.ts +22 -4
  85. package/dist/core/codegen/well-known-regex.d.ts.map +1 -1
  86. package/dist/core/codegen/well-known-regex.js +27 -5
  87. package/dist/core/codegen/well-known-regex.js.map +1 -1
  88. package/dist/core/extract/extractors/default.d.ts.map +1 -1
  89. package/dist/core/extract/extractors/default.js +6 -1
  90. package/dist/core/extract/extractors/default.js.map +1 -1
  91. package/dist/core/extract/extractors/object.d.ts.map +1 -1
  92. package/dist/core/extract/extractors/object.js +11 -5
  93. package/dist/core/extract/extractors/object.js.map +1 -1
  94. package/dist/core/extract/index.d.ts +3 -3
  95. package/dist/core/extract/index.d.ts.map +1 -1
  96. package/dist/core/extract/index.js +2 -2
  97. package/dist/core/extract/index.js.map +1 -1
  98. package/dist/core/extract/registry.d.ts +2 -2
  99. package/dist/core/extract/registry.d.ts.map +1 -1
  100. package/dist/core/extract/registry.js +4 -5
  101. package/dist/core/extract/registry.js.map +1 -1
  102. package/dist/core/extract/types.d.ts +0 -11
  103. package/dist/core/extract/types.d.ts.map +1 -1
  104. package/dist/core/iife.d.ts.map +1 -1
  105. package/dist/core/iife.js +3 -2
  106. package/dist/core/iife.js.map +1 -1
  107. package/dist/core/pipeline.d.ts +0 -2
  108. package/dist/core/pipeline.d.ts.map +1 -1
  109. package/dist/core/pipeline.js +1 -3
  110. package/dist/core/pipeline.js.map +1 -1
  111. package/dist/core/types.d.ts +11 -0
  112. package/dist/core/types.d.ts.map +1 -1
  113. package/dist/swc.d.ts.map +1 -1
  114. package/dist/swc.js +0 -1
  115. package/dist/swc.js.map +1 -1
  116. package/dist/unplugin/hoist-compile.d.ts +1 -1
  117. package/dist/unplugin/hoist-compile.d.ts.map +1 -1
  118. package/dist/unplugin/hoist-compile.js +4 -4
  119. package/dist/unplugin/hoist-compile.js.map +1 -1
  120. package/dist/unplugin/index.d.ts.map +1 -1
  121. package/dist/unplugin/index.js +0 -3
  122. package/dist/unplugin/index.js.map +1 -1
  123. package/dist/unplugin/transform.d.ts.map +1 -1
  124. package/dist/unplugin/transform.js +1 -2
  125. package/dist/unplugin/transform.js.map +1 -1
  126. package/dist/unplugin/types.d.ts +0 -23
  127. package/dist/unplugin/types.d.ts.map +1 -1
  128. package/dist/unplugin/types.js.map +1 -1
  129. package/dist/unplugin/virtual.d.ts.map +1 -1
  130. package/dist/unplugin/virtual.js +11 -7
  131. package/dist/unplugin/virtual.js.map +1 -1
  132. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Compile Zod schemas into zero-overhead validation functions at build time.**
4
4
 
5
- Keep your existing Zod schemas. Get **2-75x faster** validation. No code changes required.
5
+ Keep your existing Zod schemas. Get **2-45x faster** validation. No code changes required.
6
6
 
7
7
  - [What Gets Compiled](#what-gets-compiled)
8
8
  - [Schema Hoisting](#schema-hoisting)
@@ -38,48 +38,15 @@ import { z } from "zod";
38
38
  export const CreateUserSchema = z.object({
39
39
  name: z.string().min(1).max(100),
40
40
  email: z.email(),
41
- age: z.number().int().min(0).max(150),
42
41
  role: z.enum(["admin", "editor", "viewer"]),
43
42
  });
44
-
45
- export const UpdateUserSchema = z.object({
46
- name: z.string().min(1).max(100).optional(),
47
- email: z.email().optional(),
48
- });
49
-
50
- export const ListUsersSchema = z.object({
51
- page: z.number().int().min(1).optional().default(1),
52
- limit: z.number().int().min(1).max(100).optional().default(20),
53
- });
54
- ```
55
-
56
- **Use them as usual:**
57
-
58
- ```typescript
59
- const user = CreateUserSchema.parse(data); // throws on failure
60
- const result = CreateUserSchema.safeParse(data); // { success, data/error }
61
43
  ```
62
44
 
63
- **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`:
64
-
65
- ```typescript
66
- if (CreateUserSchema.is(data)) {
67
- data.email; // narrowed to the schema's output type
68
- }
69
- const valid = items.filter((x) => CreateUserSchema.is(x));
70
- ```
71
-
72
- 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).
73
-
74
- At build time, the plugin:
75
-
76
- 1. Finds every file with `import ... from "zod"` (skips type-only imports)
77
- 2. Statically pre-filters: files whose exports provably can't be schemas (functions, components, constants) are skipped without ever being executed
78
- 3. Executes the remaining candidates and detects exported Zod schemas
79
- 4. Compiles each schema into an optimized validator
80
- 5. Replaces the export with a tree-shakeable IIFE that preserves the full Zod API
45
+ Use them as usual. Compiled methods are installed on the original schema object, so `.shape`, `._zod`,
46
+ Standard Schema, `instanceof` and `z.toJSONSchema()` keep working and consumers need no changes.
81
47
 
82
- **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.
48
+ Compiled schemas also expose **`.is(input): input is T`** the compiled check itself, allocating
49
+ nothing. A drop-in replacement for `safeParse(x).success`.
83
50
 
84
51
  ### 2. compile() (Explicit)
85
52
 
@@ -123,9 +90,6 @@ npx zod-compiler generate src/ --schemas explicit --emit bag
123
90
 
124
91
  # Compact output: fast path only, cold errors delegated to Zod (~70% smaller)
125
92
  npx zod-compiler generate src/ --emit compact
126
-
127
- # Strip unknown keys from z.object() output (matches Zod's default .parse())
128
- npx zod-compiler generate src/ --strip-unknown-keys
129
93
  ```
130
94
 
131
95
  ## Build Plugin
@@ -147,18 +111,17 @@ npx zod-compiler generate src/ --strip-unknown-keys
147
111
 
148
112
  ### Options
149
113
 
150
- | Option | Type | Default | Description |
151
- | ------------------ | -------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
- | `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 |
153
- | `include` | `string[]` | — | Only process files matching these path globs (picomatch, matched anywhere in the path; plain substrings work too) |
154
- | `exclude` | `string[]` | — | Skip files matching these path globs (same matching rules as `include`) |
155
- | `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](#compact-output-output-compact) |
156
- | `verbose` | `boolean` | `false` | Log per-schema compilation status during build |
157
- | `hoist` | `boolean` | `true` | Hoist 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 |
158
- | `apply` | `"build" \| "serve" \| "all"` | builds + Vitest | **Vite 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 |
159
- | `codegenMode` | `"lean" \| "inline"` | auto | Override 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/` |
160
- | `stripUnknownKeys` | `boolean` | `false` | Strip 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](#behavioral-differences-from-zod) |
161
- | `cache` | `boolean \| string` | `true` | Persistent 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 |
114
+ | Option | Type | Default | Description |
115
+ | ------------- | -------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------- |
116
+ | `schemas` | `"auto" \| "explicit"` | `"auto"` | `"auto"` compiles every exported schema (and hoisted in-function ones); `"explicit"` only `compile()` calls |
117
+ | `include` | `string[]` | — | Only process files matching these path globs |
118
+ | `exclude` | `string[]` | — | Skip files matching these path globs |
119
+ | `output` | `"schema" \| "bag" \| "compact"` | `"schema"` | What a compiled export evaluates to — see [Compact Output](#compact-output-output-compact) |
120
+ | `verbose` | `boolean` | `false` | Log per-schema compilation status |
121
+ | `hoist` | `boolean` | `true` | Move schemas built inside functions to module scope see [Schema Hoisting](#schema-hoisting) |
122
+ | `apply` | `"build" \| "serve" \| "all"` | builds + Vitest | **Vite only**: when the plugin runs |
123
+ | `codegenMode` | `"lean" \| "inline"` | auto | `"inline"` emits helpers per file; needed for transpile-only esbuild see [SWC](#swc) |
124
+ | `cache` | `boolean \| string` | `true` | Persistent transform cache in `node_modules/.cache/zod-compiler` |
162
125
 
163
126
  ```typescript
164
127
  zodCompiler({
@@ -185,349 +148,101 @@ export default defineConfig({
185
148
 
186
149
  ### Bun
187
150
 
188
- zod-compiler is a **build-time** tool, so on Bun it compiles wherever your code
189
- passes through a build step; everywhere else schemas still run as plain Zod
190
- (correct, just not accelerated). The Bun plugin requires **Bun ≥ 1.2.22**.
191
-
192
- **Bundled code** (a frontend, or a server you `bun build --target=bun` first) —
193
- register the plugin and every exported (and hoisted) schema compiles:
151
+ zod-compiler is a build-time tool, so on Bun it applies wherever your code passes through a build step.
152
+ Requires **Bun 1.2.22**.
194
153
 
195
154
  ```typescript
196
155
  import zodCompiler from "zod-compiler/bun";
197
156
 
198
- await Bun.build({
199
- entrypoints: ["./src/index.tsx"],
200
- outdir: "./dist",
201
- plugins: [zodCompiler()],
202
- });
157
+ await Bun.build({ entrypoints: ["./src/index.tsx"], outdir: "./dist", plugins: [zodCompiler()] });
203
158
  ```
204
159
 
205
- **Code run straight from source** (`bun run src/server.ts`) a `Bun.build` plugin
206
- doesn't run here, so use the [CLI](#3-cli-no-bundler) to compile ahead of time and
207
- import the output:
208
-
209
- ```bash
210
- bunx zod-compiler generate src/ -o src/compiled/ # add --watch during development
211
- ```
160
+ For code run straight from source (`bun run src/server.ts`) no build plugin fires — use the
161
+ [CLI](#3-cli-no-bundler) to compile ahead of time.
212
162
 
213
163
  ### Schema Hoisting
214
164
 
215
- Schemas defined inside functions are rebuilt on every call a hidden cost in
216
- React components, request handlers, and helpers. With `hoist` (on by default),
217
- the plugin moves them to module scope:
165
+ Schemas built inside functions are rebuilt on every call. With `hoist` (on by default) they move to
166
+ module scope:
218
167
 
219
168
  ```typescript
220
- // before
169
+ // before // after
221
170
  function getSchema() {
222
- return z.object({ name: z.string() }); // rebuilt per call
171
+ const _zh_94b7 = z.object({ name: z.string() });
172
+ return z.object({ name: z.string() });
173
+ function getSchema() {}
174
+ return _zh_94b7;
223
175
  }
224
-
225
- // after (build output)
226
- const _zh_94b7f5c1 = z.object({ name: z.string() });
227
- function getSchema() {
228
- return _zh_94b7f5c1; // built once per module
229
- }
230
- ```
231
-
232
- Hoisting is conservative: only expressions built purely from **imported
233
- bindings and literals** move. Anything referencing local variables,
234
- module-level bindings, `this`, or eagerly-evaluated globals (`new Date()`,
235
- `Math.random()`) stays where it is — though safe globals inside callbacks
236
- (`refine((v) => Number.isFinite(v))`) are fine, since callbacks run per parse
237
- regardless. Inline `.parse(...)` calls are peeled so evaluation stays at the
238
- call site (`z.string().parse(x)` → `_zh_….parse(x)`), names that are ever
239
- shadowed (`function f(z) {...}`) disqualify hoists referencing them, and
240
- identical schemas dedupe to a single binding.
241
-
242
- Combinator chains on imported schemas also qualify: bases matching
243
- `schemaNamePattern` (default `/ZodSchema$/`) or chains containing an inline
244
- `z.*` reference (`Base.extend({ a: z.string() })`). Configure via
245
- `hoist: { schemaNamePattern: /Shape$/ }` (string and `null` accepted).
246
-
247
- #### Hoisted schemas compile too (auto mode)
248
-
249
- The most common shape this rescues is a schema that never leaves a function —
250
- a [slonik](https://github.com/gajus/slonik) query, a tRPC input, a handler-local
251
- validator. It is not exported, so export scanning alone would never see it:
252
-
253
- ```typescript
254
- import { pool, sql } from "./db.js";
255
- import { z } from "zod";
256
-
257
- const getUser = (id: number) => {
258
- return pool.one(
259
- sql.type(
260
- z.object({
261
- id: z.number(),
262
- name: z.string(),
263
- }),
264
- )`SELECT id, name FROM users WHERE id = ${id}`,
265
- );
266
- };
267
176
  ```
268
177
 
269
- In auto mode (the default), the build output is (verbatim, lightly trimmed):
178
+ Only expressions built from imported bindings and literals move; anything touching locals, `this` or
179
+ `new Date()` stays put. Combinator chains on imported schemas qualify via `schemaNamePattern`
180
+ (default `/ZodSchema$/`).
270
181
 
271
- ```typescript
272
- import { __zcFin, __zcFinD, __zcIT, __zcMkv } from "virtual:zod-compiler/runtime";
273
- const _zh_6c9cb1a3 = /* @__PURE__ */ (() => {
274
- function __fc_0(input) {
275
- return (
276
- typeof input === "object" &&
277
- input !== null &&
278
- !Array.isArray(input) &&
279
- Number.isFinite(input["id"]) &&
280
- typeof input["name"] === "string"
281
- );
282
- }
283
- function __sw_2(input) {
284
- var _e = [];
285
- /* error-collecting walk — runs only when .error is read */ return _e;
286
- }
287
- function safeParse__zh_6c9cb1a3(input) {
288
- if (__fc_0(input)) {
289
- return { success: true, data: input };
290
- }
291
- return __zcFinD(__sw_2, input);
292
- }
293
- return __zcMkv(
294
- safeParse__zh_6c9cb1a3,
295
- z.object({
296
- id: z.number(),
297
- name: z.string(),
298
- }),
299
- __fc_0,
300
- );
301
- })();
302
- import { pool, sql } from "./db.js";
303
- import { z } from "zod";
304
-
305
- const getUser = (id: number) => {
306
- return pool.one(sql.type(_zh_6c9cb1a3)`SELECT id, name FROM users WHERE id = ${id}`);
307
- };
308
- ```
309
-
310
- Reading it bottom-up:
311
-
312
- - **The real Zod schema is still constructed** (once, at module load) and is the
313
- object `_zh_6c9cb1a3` resolves to — `__zcMkv` installs the compiled
314
- `parse`/`safeParse`/`parseAsync`/`safeParseAsync` as own properties on it and
315
- returns it. `sql.type()` receives a genuine Zod schema (identity, `.shape`,
316
- `._zod`, Standard Schema all intact) whose `safeParse` happens to be compiled.
317
- - **`__fc_0` is the Fast Path**: when slonik validates each row, a valid row
318
- costs one boolean chain — no per-node traversal, no allocations beyond the
319
- result object.
320
- - **`__sw_2` + `__zcFinD` are the failure path**: an invalid row returns
321
- `{success: false}` immediately; the full error walk runs lazily only if
322
- `.error` is actually read.
323
- - The `sql.type(...)` call itself stays at the call site (it closes over `id`
324
- via the tagged template) — only its schema argument was hoisted and compiled.
325
-
326
- Measured on this exact pattern: schema construction + validation drops from
327
- ~16,700ns to ~14ns per call — construction amortizes to module load, and
328
- per-row validation rides the Fast Path. With `schemas: "explicit"` the same file
329
- still gets the plain hoist (construction once instead of per call); the
330
- compiled IIFE requires auto mode (the default) because the schema is anonymous.
182
+ In auto mode hoisted schemas also **compile** — which rescues the schema that never leaves a function
183
+ (a slonik query, a tRPC input), invisible to export scanning. Measured on that pattern: ~16,700 ns →
184
+ ~14 ns per call.
331
185
 
332
186
  ### Bundle Size & Cross-File Dedup
333
187
 
334
- Generated validators share a small runtime helper layer (`__zcMkv` validator
335
- wrapper, issue factories like `__zcTS`/`__zcIT`, and well-known regexes for
336
- `email`, `uuid`, `cuid`, `ipv4`, etc.).
337
-
338
- On every supported bundler the plugin imports these helpers from a single
339
- plugin-provided runtime module — `virtual:zod-compiler/runtime` on Vite,
340
- Rollup, Rolldown, esbuild, Farm, and Bun, or the bare-specifier alias
341
- `__zod-compiler-runtime__` on webpack and rspack (which reject the `virtual:`
342
- URI scheme) — so the bundler emits a single bundle-wide copy regardless of how
343
- many files reference them.
188
+ Validators share a runtime helper layer imported from one module, so each helper appears once per
189
+ bundle. Schemas in a file sharing a structurally identical sub-shape emit its error walk once —
190
+ **~50% raw / ~34% gzipped** on a realistic set.
344
191
 
345
- **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:
192
+ **Transpile-only esbuild builds** (no `--bundle`) never fire the bundler's resolve hooks, so the
193
+ `virtual:` specifier would survive into `dist/` and fail at runtime. Set `codegenMode: "inline"` to emit
194
+ helpers per file instead:
346
195
 
347
196
  ```typescript
348
- import zodCompiler from "zod-compiler/esbuild";
349
197
  export default [zodCompiler({ schemas: "explicit", codegenMode: "inline" })];
350
198
  ```
351
199
 
352
- The result: a 5-file project with 10 schemas all using `z.email()` and
353
- `z.uuid()` produces a bundle where each shared regex appears exactly **once**.
354
- Set `output: "bag"` to additionally drop the original Zod schema reference
355
- when you don't need `instanceof` / `.shape` access on the compiled output.
200
+ Set `output: "bag"` to also drop the retained Zod schema when you don't need `.shape` / `instanceof`.
356
201
 
357
202
  ### SWC
358
203
 
359
- `zod-compiler/swc` is a **programmatic `@swc/core` bridge**, not a native
360
- `.swcrc` WASM plugin. Native SWC plugins run inside SWC's Rust/WASM plugin
361
- runtime; zod-compiler needs Node.js at build time to execute schema modules
362
- for discovery, so the SWC integration wraps `@swc/core.transform()` instead.
363
-
364
- Install `@swc/core` in the consuming project:
365
-
366
- ```bash
367
- pnpm add -D @swc/core
368
- ```
369
-
370
- Use the default factory when you want shared SWC/zod-compiler defaults:
371
-
372
- ```typescript
373
- import zodCompiler from "zod-compiler/swc";
374
-
375
- const compiler = zodCompiler({
376
- swc: {
377
- jsc: {
378
- parser: { syntax: "typescript", tsx: true },
379
- },
380
- sourceMaps: true,
381
- },
382
- zodCompiler: {
383
- schemas: "auto",
384
- },
385
- });
386
-
387
- const result = await compiler.transform(sourceCode, {
388
- filename: "src/schemas.ts",
389
- });
390
- ```
391
-
392
- Or use the one-shot helper:
204
+ `zod-compiler/swc` is a programmatic `@swc/core` bridge, not a `.swcrc` WASM plugin — discovery needs
205
+ Node.js, so it wraps `transform()`. Install `@swc/core`, then:
393
206
 
394
207
  ```typescript
395
208
  import { transform } from "zod-compiler/swc";
396
209
 
397
210
  const result = await transform(sourceCode, {
398
211
  filename: "src/schemas.ts",
399
- swc: {
400
- jsc: {
401
- parser: { syntax: "typescript" },
402
- },
403
- },
212
+ swc: { jsc: { parser: { syntax: "typescript" } } },
404
213
  });
405
214
  ```
406
215
 
407
- The bridge defaults `codegenMode` to `"inline"` because SWC does not provide
408
- Rollup-style virtual module hooks for `virtual:zod-compiler/runtime`. If your
409
- pipeline runs another bundler after SWC and that bundler resolves the runtime
410
- specifier, you can opt into smaller lean output:
411
-
412
- ```typescript
413
- await transform(sourceCode, {
414
- filename: "src/schemas.ts",
415
- zodCompiler: { codegenMode: "lean" },
416
- });
417
- ```
418
-
419
- The bridge honors `include`/`exclude` globs (rejected files pass through to
420
- SWC without the zod-compiler step) and re-runs schema discovery when a
421
- file's content changes between calls, so watch-mode hosts pick up schema
422
- edits. It keeps no persistent disk cache — long-running hosts that want one
423
- should key cached transform results on file content.
424
-
425
- **Structural dedup within a file.** Beyond the shared runtime layer, schemas in
426
- the same file that contain a structurally identical sub-tree — a reused
427
- `Address`, a `Money` pair, an exported schema also embedded in another — emit
428
- that shape's error-collecting walk **once** as a shared function and call it
429
- from every occurrence. Only the cold error path is shared (it's 60–80% of the
430
- generated bytes); the zero-allocation fast path stays fully inlined, so valid
431
- input runs exactly as fast as before. On a realistic schema set where
432
- `User`/`Company`/`Order`/`Invoice` reuse `Address`/`Money`/`Contact`, generated
433
- output drops **~50% raw / ~34% gzipped** with no change to validation behavior.
216
+ Defaults `codegenMode` to `"inline"` (SWC has no virtual-module hook); pass
217
+ `zodCompiler: { codegenMode: "lean" }` if a later bundler resolves the runtime specifier. Honours
218
+ `include`/`exclude` and keeps no disk cache.
434
219
 
435
220
  ### Compact Output (`output: "compact"`)
436
221
 
437
- Structural dedup only helps when shapes _repeat_. For a large app of mostly
438
- **distinct** schemas it can't fire, and the per-schema error-collecting walk
439
- 64–77% of the generated bytes is emitted in full for every schema. But that
440
- walk exists only to reproduce Zod's issues on failure, and in `"schema"` mode
441
- **the original Zod schema is already in your bundle**. `output: "compact"`
442
- exploits this: it compiles the fast path as usual and, on a fast-check failure,
443
- delegates the cold error path to the retained schema's own `safeParse` instead
444
- of emitting a compiled slow walk.
445
-
446
- ```typescript
447
- import zodCompiler from "zod-compiler/vite";
448
-
449
- export default defineConfig({
450
- plugins: [zodCompiler({ output: "compact" })],
451
- });
452
- ```
222
+ The error-collecting walk is 64–77% of the generated bytes, and it only reproduces Zod's issues on
223
+ failure which the retained Zod schema already does. `output: "compact"` (CLI: `--emit compact`)
224
+ compiles the fast path and delegates the cold error path to it.
453
225
 
454
- On a 50-schema set of distinct shapes (where dedup can't help), generated
455
- output drops **~73% raw / ~71% gzipped**:
456
-
457
- | Mode | Raw | Gzip |
458
- | --------- | ------: | -----: |
459
- | `schema` | 169,599 | 16,645 |
460
- | `compact` | 45,735 | 4,789 |
461
-
462
- The gzip win is far larger than collapsing duplicated code (which gzip already
463
- compresses well) because the slow walk is **removed**, not re-encoded.
464
-
465
- **What it costs — and doesn't:**
466
-
467
- - **Hot path unchanged.** `parse`/`safeParse` of valid input and the `.is()`
468
- guard run the exact same compiled fast check as `"schema"` mode. Identity is
469
- preserved (`.shape`, `.meta()`, `z.toJSONSchema()`, `instanceof`, tRPC/Hono
470
- all keep working).
471
- - **Errors are Zod's own.** A failed `safeParse` reports byte-identical issues
472
- to Zod — there is no second validation engine to drift, so correctness is
473
- guaranteed by construction.
474
- - **Cold error reporting runs Zod.** Reading `.error` (or `.parse()` throwing)
475
- on _invalid_ input runs Zod's full parse — slower than the compiled slow walk,
476
- but it's the cold path. The delegation is **lazy**: `safeParse(x).success` and
477
- `.is(x)` never invoke Zod (the fast check alone decides), so the common
478
- validation-failure checks stay fast.
479
- - **Mutation schemas keep the compiled path.** Schemas that transform their
480
- input (`default` / `catch` / `coerce` / `transform`) are compiled exactly as
481
- in `"schema"` mode — only pure validators delegate.
482
-
483
- Use it when bundle size dominates (large schema counts, edge/serverless cold
484
- starts, memory at scale) and you can afford a slower _error_ path. It requires
485
- the Zod schema, so it's mutually exclusive with `output: "bag"` (which drops it).
486
- The CLI exposes it as `--emit compact`.
226
+ On 50 distinct schemas, output drops **~73% raw / ~71% gzipped**. The hot path is unchanged, errors are
227
+ Zod's own, and `safeParse(x).success` / `.is(x)` never invoke Zod — only reading `.error` does. Mutation
228
+ schemas keep the compiled path. Mutually exclusive with `output: "bag"`.
487
229
 
488
230
  ### Auto Mode: Side Effects Warning
489
231
 
490
- 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.
491
-
492
- **Fix:** Use `include` to limit which files are scanned:
493
-
494
- ```typescript
495
- zodCompiler({
496
- include: ["src/schemas", "src/validators"],
497
- });
498
- ```
499
-
500
- #### Environment validation that calls `process.exit`
501
-
502
- A common pattern is an `env.ts` that validates `process.env` and calls
503
- `process.exit(1)` when required secrets are missing — schema files often import
504
- it transitively. In a CI build those secrets are intentionally absent, so
505
- executing the file at build time would otherwise terminate the bundler.
506
-
507
- zod-compiler guards against this. While it executes a module for discovery it:
232
+ Auto mode executes files to inspect their exports, so a file with schema-shaped exports **and** side
233
+ effects runs them at build time. Limit the scan with `include`.
508
234
 
509
- 1. Sets `process.env.ZOD_COMPILER` so cooperating modules can skip validation.
510
- 2. Intercepts `process.exit` an unguarded exit becomes a normal load failure,
511
- so the build **does not crash**. The affected files fall back to runtime Zod
512
- and a one-time warning names the optimization that was skipped.
513
-
514
- To keep those schemas compiled, guard the exit on the marker:
235
+ For the common `env.ts` that validates `process.env` and exits, zod-compiler sets
236
+ `process.env.ZOD_COMPILER` during discovery and intercepts `process.exit`, so the build never crashes —
237
+ those files just fall back to runtime Zod. To keep them compiled, guard on it:
515
238
 
516
239
  ```typescript
517
- // env.ts
518
240
  if (!process.env.ZOD_COMPILER) {
519
- const result = envSchema.safeParse(process.env);
520
- if (!result.success) {
521
- console.error("Missing required environment variables:", result.error.format());
522
- process.exit(1);
523
- }
241
+ // ...validate and exit
524
242
  }
525
243
  ```
526
244
 
527
- If you use `@t3-oss/env-*`, pass `skipValidation: !!process.env.ZOD_COMPILER`.
528
-
529
- (Only synchronous exits during module evaluation are intercepted — an exit
530
- deferred to a `setTimeout` or later event still exits.)
245
+ With `@t3-oss/env-*`, pass `skipValidation: !!process.env.ZOD_COMPILER`.
531
246
 
532
247
  ### schemas: "auto" vs "explicit"
533
248
 
@@ -541,111 +256,38 @@ deferred to a `setTimeout` or later event still exits.)
541
256
 
542
257
  ### Large projects and CI
543
258
 
544
- Discovery executes each schema file and transitively its first-party import
545
- graphinside the bundler's single-threaded process. In a repository where
546
- schema files pull in thousands of modules, the **first cold run** is the
547
- expensive part: subsequent runs hit the persistent cache and skip discovery
548
- entirely. On saturated CI hosts a cold discovery of a huge graph can stall the
549
- bundler's event loop long enough to trip test timeouts (the plugin warns when
550
- a single file's discovery exceeds 5s). Three levers, in order of impact:
551
-
552
- **1. Persist the cache across CI runs.** The cache directory is small
553
- (dependency snapshots are content-addressed and shared between entries) and
554
- entries self-validate against dependency content hashes — restoring a stale
555
- cache can only cause recompiles, never stale output:
259
+ Discovery executes each schema file inside the bundler's process, so the **first cold run** is the
260
+ expensive one later runs hit the persistent cache.
556
261
 
557
262
  ```yaml
558
- # GitHub Actions
559
263
  - uses: actions/cache@v4
560
264
  with:
561
265
  path: node_modules/.cache/zod-compiler
562
266
  key: zod-compiler-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
563
- restore-keys: zod-compiler-${{ runner.os }}-
564
- ```
565
-
566
- **2. Scope what gets discovered.** `include` limits discovery to your schema
567
- directories. If test startup latency matters more than test-time validator
568
- performance, run hoist-only in Vitest and compile only real builds:
569
-
570
- ```typescript
571
- // vitest.config.ts — hoisting still applies; validation uses plain Zod
572
- zodCompiler({ schemas: "explicit" });
573
-
574
- // vite.config.ts (build)
575
- zodCompiler({ include: ["src/schemas"] });
576
267
  ```
577
268
 
578
- **3. Measure before tuning.** `ZOD_COMPILER_TIMING=1` prints per-phase wall
579
- time (hoist / static-filter / discover / compile) on exit, so you can see
580
- whether discovery or codegen dominates and which files pay it.
581
-
582
- Everything else is automatic: the plugin declares
583
- [hook filters](https://vite.dev/guide/rolldown.html#hook-filter-feature) on
584
- its `transform`, `load` and `resolveId` hooks, so bundlers that support them
585
- (Vite, Rolldown, Rollup ≥ 4.40) never call the plugin for a module that cannot
586
- contain a schema — a file that never mentions `zod` costs nothing at all, not
587
- even a hook call. Bundlers without native support get the same filtering in
588
- JavaScript. The one exception is a custom `hoist.schemaNamePattern`: it
589
- promotes arbitrary imported identifiers to schema roots, so the content filter
590
- is dropped for that configuration (path filtering still applies).
269
+ Cache entries self-validate against dependency hashes, so a stale cache can only cause recompiles.
270
+ Scope discovery with `include`; set `ZOD_COMPILER_TIMING=1` to see per-phase wall time. Files that never
271
+ mention `zod` cost nothing.
591
272
 
592
273
  ## Framework Examples
593
274
 
594
- ### tRPC
595
-
596
- ```typescript
597
- // src/schemas.ts
598
- import { z } from "zod";
599
-
600
- export const CreateUserSchema = z.object({
601
- name: z.string().min(1).max(100),
602
- email: z.email(),
603
- age: z.number().int().min(0).max(150),
604
- });
605
-
606
- // src/router.ts
607
- import { CreateUserSchema } from "./schemas";
608
-
609
- export const appRouter = t.router({
610
- createUser: t.procedure.input(CreateUserSchema).mutation(({ input }) => createUser(input)),
611
- });
612
- ```
613
-
614
- In auto mode (the default), `CreateUserSchema` is compiled at build time. The tRPC router uses the optimized version automatically. No `.input(compile(CreateUserSchema))` needed.
615
-
616
- ### Hono
275
+ Nothing framework-specific is needed. In auto mode your exported schemas are compiled in place, so
276
+ anything that accepts a Zod schema keeps working and picks up the compiled version:
617
277
 
618
278
  ```typescript
619
- import { Hono } from "hono";
620
- import { zValidator } from "@hono/zod-validator";
621
- import { UserSchema } from "./schemas";
622
-
623
- const app = new Hono();
279
+ // tRPC no .input(compile(...)) needed
280
+ t.procedure.input(CreateUserSchema).mutation(({ input }) => createUser(input));
624
281
 
625
- app.post("/users", zValidator("json", UserSchema), (c) => {
626
- const user = c.req.valid("json");
627
- return c.json(user);
628
- });
629
- ```
282
+ // Hono
283
+ app.post("/users", zValidator("json", UserSchema), (c) => c.json(c.req.valid("json")));
630
284
 
631
- ### React Hook Form
632
-
633
- ```typescript
634
- import { useForm } from "react-hook-form";
635
- import { zodResolver } from "@hookform/resolvers/zod";
636
- import { UserSchema } from "./schemas";
637
-
638
- function UserForm() {
639
- const form = useForm({
640
- resolver: zodResolver(UserSchema),
641
- });
642
- // ...
643
- }
285
+ // React Hook Form
286
+ useForm({ resolver: zodResolver(SignupSchema) });
644
287
  ```
645
288
 
646
- ### Any Standard Schema Consumer
647
-
648
- Compiled schemas are the original Zod schema objects with optimized parse methods installed, so they still implement [Standard Schema](https://standardschema.dev). Any library that accepts Standard Schema validators works automatically.
289
+ The same applies to any [Standard Schema](https://standardschema.dev) consumer — `~standard` is
290
+ preserved and routes through the compiled validator.
649
291
 
650
292
  ## Schema Diagnostics
651
293
 
@@ -698,115 +340,47 @@ npx zod-compiler check src/schemas.ts --json --fail-under 80
698
340
 
699
341
  ## What Gets Compiled
700
342
 
701
- ### Fully Compiled (2-75x faster)
343
+ ### Fully Compiled (2-45x faster)
702
344
 
703
- `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)
345
+ Every Zod type except the fallbacks below all primitives, `object` / `strictObject` / `looseObject`,
346
+ `array`, `tuple`, `record`, `set`, `map`, `union`, `discriminatedUnion`, `intersection`, `pipe`,
347
+ the `optional` / `nullable` / `readonly` / `default` / `catch` / `coerce` wrappers, `templateLiteral`,
348
+ recursive `lazy` (self, mutual and nested), and `transform` / `refine` / `superRefine`.
704
349
 
705
- 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.
350
+ All standard checks are supported: `min`, `max`, `length`, `email`, `uuid`, `regex`, `int`, `positive`,
351
+ `multipleOf`, `includes`, `startsWith`, and the rest.
706
352
 
707
353
  ### Falls Back to Zod (Still Works, Not Faster)
708
354
 
709
- These reach JavaScript that generated code cannot reproduce an opaque callback, or control flow that depends on one:
710
-
711
- | Type | Why | Alternative |
712
- | ---------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
713
- | `transform` that is async or takes `ctx` | A promise result, or zod's issue-collection protocol | Use a plain single-argument callback |
714
- | `superRefine` followed by another check | An aborting issue truncates zod's remaining checks | Put the `superRefine` last in the chain |
715
- | `custom` / raw `.check(fn)` | Arbitrary validation logic against zod's raw payload | Use `superRefine`, which compiles |
716
- | `preprocess` | Input preprocessing function | Use `z.coerce` when possible |
717
- | `lazy` (unresolvable inner) | Getter throws / inner type can't be resolved at compile time | Ensure the lazy getter returns a static schema |
718
-
719
- **Zero-capture effects compile:** a `transform`/`refine` callback that takes a
720
- single argument and references only its own parameters, locals, and safe
721
- globals (`Math`, `Number`, `JSON`, …) is extracted via `fn.toString()` and
722
- inlined into the generated validator. `z.string().transform((s) => s.trim())`
723
- compiles; `z.string().transform((s) => s + suffix)` falls back (it captures
724
- `suffix`).
725
-
726
- **Every `refine` and `transform` compiles, captures or not.** A callback that
727
- cannot be inlined is instead **called by reference** — the generated validator
728
- invokes your own function object, reached from the schema — so the schema keeps
729
- its compiled path either way. This matters most on the root, where one captured
730
- callback used to send the whole object through Zod:
731
-
732
- - `z.object({ … }).refine((d) => d.password === d.confirm)` — the cross-field
733
- check. Measured on a six-field object: **246.7 ns → 8.5 ns (29x)**, against
734
- Zod's own 250.5 ns.
735
- - `z.object({ … }).transform((d) => ({ ...d, id: prefix + d.id }))` —
736
- **163.7 ns → 12.7 ns**. That case was previously _slower than not compiling
737
- at all_ (Zod itself: 136.7 ns), because the delegate wrapper sat on top of
738
- Zod's parse.
739
-
740
- Where the callback itself dominates, compiled output reaches its cost and no
741
- more: a captured `refine` doing `allowedDomains.some(…)` measures 23.9 ns
742
- against 24.1 ns for calling that predicate alone — zero remaining validation
743
- overhead.
744
-
745
- **`superRefine` compiles too.** It has no verdict to read — the callback takes
746
- Zod's payload and pushes issues onto it — so it is called through a reference to
747
- Zod's own wrapper with a synthesized payload, which leaves issue construction
748
- Zod's job and keeps error shapes identical by construction. That turns the most
749
- common form of cross-field validation from a total fallback into the 2.3M →
750
- 13.3M row below; measured in isolation, where the harness floor stops
751
- compressing the ratio, the same object runs 357.8 ns → 39.9 ns (9.0x) and an
752
- array of numbers 240.7 ns → 5.3 ns (46x), with rejecting inputs 31-92x. Two
753
- shapes still delegate: a `superRefine` with another check after it
754
- (an issue marked `fatal` aborts Zod's remaining chain, which generated code —
755
- running every check — cannot reproduce), and a raw `.check(fn)`, where the
756
- callback holds the payload unmediated rather than through Zod's wrapper. An
757
- `async` callback is invisible at compile time, since the reference points at the
758
- wrapper; the promise it returns raises Zod's own `$ZodAsyncError`, exactly as a
759
- synchronous Zod parse does. `ctx.value`, `ctx.aborted`, and direct
760
- `ctx.issues.push` are all honored.
761
-
762
- **`.catchall(schema)` compiles.** Unknown keys are validated against the value
763
- schema by the same bare `for-in` the `strict` pass uses — zod's own
764
- `handleCatchall` iteration, inherited enumerable keys included — with each issue
765
- reported at its key. A header bag (`z.object({...}).catchall(z.string())`)
766
- measures 117.2 ns → 9.0 ns (**13x**), a numeric metrics bag 105.1 ns → 11.5 ns
767
- (9.1x); both were 1.0x. Value-rewriting catchalls (`z.coerce.number()`,
768
- `.trim()`, a `.default()`) write through a clone, so the caller's input is never
769
- mutated. A catchall that itself delegates keeps the whole object delegated —
770
- one zod call beats one per unknown key.
771
-
772
- **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.
773
-
774
- **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–32x faster than Zod** (see the benchmark table). A `lazy` schema only falls back when its getter can't be resolved at compile time.
775
-
776
- **Tip:** Run `npx zod-compiler check` to see exactly which parts of your schemas are compiled and which fall back.
777
-
778
- ### Behavioral Differences from Zod
779
-
780
- 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.
355
+ A schema delegates to Zod when it reaches JavaScript the generated code cannot reproduce:
781
356
 
782
- | Behavior | Zod | zod-compiler |
783
- | -------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------- |
784
- | Unknown keys on a default `z.object()` | Stripped from the output | **Kept** by default returned by reference; opt in with `stripUnknownKeys` |
785
- | Record key iteration | All own keys (`Reflect.ownKeys`) | Own **enumerable string** keys only symbol and non-enumerable keys are ignored |
786
- | Array / object output identity | A fresh value | The input value, returned by reference |
357
+ | Construct | Why |
358
+ | ------------------------------------------- | -------------------------------------------------------------------------- |
359
+ | `.check(fn)`, `superRefine` + later checks | The callback holds Zod's payload unmediated, or `fatal` aborts Zod's chain |
360
+ | `ctx`-taking or `async` transforms | Needs Zod's parse context / the async pipeline |
361
+ | `z.custom()`, `z.instanceof()` | No extractor yet |
362
+ | `z.url()`, `z.jwt()` | Algorithmic formats (`new URL()`, signature parsing) |
363
+ | Object intersections | Zod parses both sides and merges; the compiler cannot reproduce the merge |
364
+ | Dynamic error maps, unresolvable `z.lazy()` | Not knowable at build time |
787
365
 
788
- What this means in practice:
366
+ Everything else compiles, including `transform`/`refine`/`superRefine` whether or not the callback
367
+ captures — a zero-capture one is inlined, a capturing one called by reference. Delegation is
368
+ per-sub-schema: one uncompilable field goes to Zod, not the whole object. Run `zod-compiler check` to
369
+ see what compiled.
789
370
 
790
- - **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:
791
- - 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.)
792
- - Use **`z.strictObject()`** if you'd rather reject unknown keys outright (changes the API contract — extras become errors).
793
- - Use **`z.looseObject()`** to make the default keep-extras behavior explicit at the schema level.
794
-
795
- All three compile fully, and validation of the declared keys is identical in every case.
796
-
797
- **Performance of `stripUnknownKeys`** (`pnpm benchmark strip-unknown-keys`): stripping rebuilds the object, so it gives up the by-reference fast path — but the rebuild is a single object literal per level, which costs little next to the validation itself. Representative throughput:
798
-
799
- | Schema (input) | Zod (strips) | compiler (keep, default) | compiler (strip) | strip vs keep | strip vs Zod |
800
- | ----------------------------- | ------------ | ------------------------ | ---------------- | ------------- | ------------ |
801
- | medium object, 7 keys (clean) | 2.7M | 13.5M | 12.7M | 0.94x | **4.7x** |
802
- | wide object, 20 keys (clean) | 4.0M | 24.4M | 18.1M | 0.74x | **4.5x** |
803
- | nested API response (clean) | 176K | 9.3M | 5.4M | 0.58x | **31x** |
371
+ ### Behavioral Differences from Zod
804
372
 
805
- The tax scales with nesting depth (one fresh object per level), not really with key count, and is independent of whether unknown keys are actually present. It is small enough that you should turn stripping on wherever you want sanitization — forwarding parsed bodies to an ORM, for instance — rather than trading it away for throughput.
373
+ Compiled validators match Zod on verdicts, output data and error messages, including issue ordering.
374
+ Two things differ by design, both from the zero-allocation fast path:
806
375
 
807
- - **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.
376
+ | Behavior | Zod | zod-compiler |
377
+ | ------------------------- | ---------------------------------- | ------------------------------------------------------- |
378
+ | Record key iteration | All own keys (`Reflect.ownKeys`) | Own enumerable **string** keys only |
379
+ | Container output identity | A fresh array / set / map / object | The input container, by reference (array holes survive) |
808
380
 
809
- 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.
381
+ A container whose contents need no rewriting is validated in place and handed back, where Zod always
382
+ rebuilds it. `z.object()` is the exception — it strips unknown keys exactly as Zod does, so its output
383
+ is always a fresh object.
810
384
 
811
385
  ## Benchmark
812
386
 
@@ -814,46 +388,46 @@ Matching Zod on these would mean allocating a fresh object (or a `Reflect.ownKey
814
388
 
815
389
  | Scenario | Zod v3 | Zod v4 | **zod-compiler** | Typia | AJV | vs Zod v4 |
816
390
  | ----------------------------------------------- | ------ | ------ | ---------------- | ----- | ----- | --------- |
817
- | simple string | 13.3M | 14.5M | **17.3M** | 17.7M | 17.9M | 1.2x |
818
- | string (min/max) | 12.4M | 8.0M | **17.0M** | 17.9M | 15.7M | 2.1x |
819
- | number (int+positive) | 12.6M | 8.3M | **17.4M** | 18.0M | 18.1M | 2.1x |
820
- | enum | 12.3M | 12.6M | **17.6M** | 17.8M | 18.3M | 1.4x |
821
- | bigint (min/max) | 12.2M | 7.8M | **17.0M** | — | — | 2.2x |
822
- | tuple [string, int, bool] | 5.9M | 6.5M | **16.5M** | 16.5M | 16.4M | 2.6x |
823
- | record\<string, number\> | 3.2M | 2.8M | **15.2M** | 11.9M | 15.7M | 5.5x |
824
- | set\<string\> (5 items) | 3.7M | 2.3M | **15.0M** | — | — | 6.5x |
825
- | set\<string\> (20 items) | 1.3M | 715K | **11.8M** | — | — | **16x** |
826
- | map\<string, number\> (5 entries) | 2.0M | 1.4M | **13.2M** | — | — | 9.6x |
827
- | map\<string, number\> (20 entries) | 665K | 360K | **8.4M** | — | — | **23x** |
828
- | pipe (non-transform) | 8.7M | 5.7M | **16.7M** | — | — | 2.9x |
829
- | discriminatedUnion (3 variants) | 3.5M | 4.2M | **16.0M** | 15.9M | 7.9M | 3.9x |
830
- | discriminatedUnion (8 variants, rotating) | 2.7M | 3.5M | **10.1M** | — | — | 2.9x |
831
- | plain union of 8 tagged objects (auto-discrim.) | 374K | 678K | **10.0M** | — | — | **15x** |
832
- | strict object (DB row) | 1.8M | 3.2M | **8.1M** | — | — | 2.5x |
833
- | medium object (valid) | 1.9M | 2.4M | **9.9M** | 11.5M | 7.6M | 4.1x |
834
- | medium object (invalid) | 553K | 80K | **15.1M** | 3.0M | 7.8M | **188x** |
835
- | large object (10 items) | 122K | 166K | **7.9M** | 6.1M | 1.2M | **48x** |
836
- | large object (100 items) | 14K | 18K | **1.4M** | 1.3M | 127K | **77x** |
837
- | recursive tree (7 nodes) | 589K | 2.1M | **12.8M** | 12.0M | 4.9M | 6.0x |
838
- | recursive tree (121 nodes) | 32K | 143K | **2.5M** | 2.0M | 392K | **18x** |
839
- | nested recursion (7 nodes) | 394K | 1.0M | **12.1M** | 11.1M | 3.0M | **12x** |
840
- | nested recursion (121 nodes) | 25K | 66K | **2.1M** | 1.7M | 219K | **32x** |
841
- | deeply nested object (243 leaves) | 11K | 20K | **1.2M** | 1.1M | 129K | **59x** |
842
- | event log (combined) | 389K | 645K | **6.4M** | | | 9.9x |
843
- | object with transform (zero-capture) | 1.2M | 2.0M | **6.3M** | — | — | 3.1x |
844
- | array 10 × transform (zero-capture) | 126K | 208K | **3.3M** | — | — | **16x** |
845
- | array 50 × transform (zero-capture) | 26K | 45K | **839K** | — | — | **19x** |
846
- | object with captured transform | 1.3M | 6.4M | **15.0M** | — | — | 2.4x |
847
- | object with captured refine (cross-field) | 1.6M | 2.5M | **16.1M** | — | — | 6.4x |
848
- | object with superRefine (cross-field) | 1.6M | 2.3M | **13.3M** | — | — | 5.7x |
849
-
850
- _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), best of two full runs; rows reproduce within ~5% between runs. The harness itself costs ~55 ns per iteration — the fastest rows sit at that floor — so it compresses the top of the range: gaps between the three AOT columns on the primitive rows are below the noise, not real._
851
-
852
- 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` dispatches instead of trying options in sequence the way Zod does, 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). Dispatch is genuinely O(1) for string discriminators: a `switch` over string labels is only _written_ as a jump, V8 lowers it to sequential `===` comparisons (~0.5 ns per preceding case, so 52 ns of pure dispatch at 80 variants), so the discriminator goes through a `{value: ordinal}` table into a dense integer switch — measured 1.9x at 8 variants and 2.8x at 60, for ~1% more generated bytes. Unions of two variants, or with non-string discriminators, keep the plain switch. 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) (15x 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, whose options and per-option checks are ordered cheapest-first so a non-matching option is dropped without running its regexes. The invalid-input row is large because failed `safeParse` defers error materialization until `.error` is read. `transform`/`refine` callbacks compile whether or not they capture (3-19x): a zero-capture one is inlined from its source, a capturing one is called by reference rather than costing the schema its compiled path — the cross-field refine row measures 2.5M → 16.1M, and captured transforms went from matching Zod (1.0x) to 2.4x. `superRefine` compiles as well, called through a reference to Zod's own payload wrapper (2.3M → 13.3M here, from a total fallback); only a `superRefine` with another check after it, raw `.check(fn)`, and `ctx`-taking or async transforms still delegate.
853
-
854
- `parse()` (throwing API) rides a zero-allocation fast path: medium object 2.4M → 10.2M ops/s (4.3x), large object (100 items) 18K → 1.4M ops/s (78x).
855
-
856
- The `.is()` guard answers both halves of "does this match?" cheaply — 10.3M ops/s on matching input and **16.8M on non-matching**, against Zod's 2.4M / 159K via `safeParse().success` (medium object). Rejection is the _faster_ half by construction: the fast check's conjuncts are ordered so a mismatch is decided on a type guard rather than on the schema's most expensive check — 106x Zod, which pays full error construction to say no.
391
+ | simple string | 12.9M | 14.5M | **16.9M** | 17.2M | 17.5M | 1.2x |
392
+ | string (min/max) | 12.1M | 7.6M | **16.3M** | 17.6M | 14.9M | 2.1x |
393
+ | number (int+positive) | 11.5M | 7.7M | **16.5M** | 16.8M | 17.7M | 2.1x |
394
+ | enum | 11.8M | 12.2M | **16.8M** | 17.1M | 17.2M | 1.4x |
395
+ | bigint (min/max) | 11.4M | 7.6M | **15.9M** | — | — | 2.1x |
396
+ | tuple [string, int, bool] | 5.5M | 6.3M | **15.9M** | 16.8M | 15.3M | 2.5x |
397
+ | record\<string, number\> | 3.2M | 2.7M | **15.5M** | 12.3M | 15.3M | 5.7x |
398
+ | set\<string\> (5 items) | 3.6M | 2.3M | **15.0M** | — | — | 6.6x |
399
+ | set\<string\> (20 items) | 1.3M | 695K | **11.9M** | — | — | **17x** |
400
+ | map\<string, number\> (5 entries) | 2.0M | 1.3M | **12.4M** | — | — | 9.5x |
401
+ | map\<string, number\> (20 entries) | 613K | 352K | **8.3M** | — | — | **24x** |
402
+ | pipe (non-transform) | 8.7M | 5.6M | **16.8M** | — | — | 3.0x |
403
+ | discriminatedUnion (3 variants) | 3.4M | 4.0M | **16.7M** | 16.0M | 7.9M | 4.1x |
404
+ | discriminatedUnion (8 variants, rotating) | 2.7M | 3.5M | **9.8M** | — | — | 2.8x |
405
+ | plain union of 8 tagged objects (auto-discrim.) | 358K | 646K | **9.5M** | — | — | **15x** |
406
+ | strict object (DB row) | 1.8M | 3.1M | **11.0M** | — | — | 3.6x |
407
+ | medium object (valid) | 1.5M | 2.3M | **9.4M** | 10.9M | 7.4M | 4.0x |
408
+ | medium object (extra keys stripped) | 1.8M | 2.2M | **9.4M** | | | 4.3x |
409
+ | medium object (invalid) | 536K | 79K | **14.2M** | 3.0M | 7.7M | **180x** |
410
+ | large object (10 items) | 118K | 162K | **5.2M** | 5.8M | 1.2M | **32x** |
411
+ | large object (100 items) | 13K | 18K | **811K** | 1.4M | 125K | **45x** |
412
+ | recursive tree (7 nodes) | 589K | 2.1M | **8.1M** | 11.9M | 4.9M | 3.8x |
413
+ | recursive tree (121 nodes) | 32K | 128K | **803K** | 1.9M | 386K | 6.3x |
414
+ | nested recursion (7 nodes) | 395K | 1.0M | **7.1M** | 8.8M | 2.5M | 7.0x |
415
+ | nested recursion (121 nodes) | 23K | 61K | **804K** | 1.4M | 184K | **13x** |
416
+ | deeply nested object (243 leaves) | 11K | 20K | **832K** | 1.0M | 124K | **42x** |
417
+ | event log (combined) | 377K | 623K | **7.3M** | — | — | **12x** |
418
+ | object with transform (zero-capture) | 1.1M | 2.0M | **6.0M** | — | — | 3.0x |
419
+ | array 10 × transform (zero-capture) | 120K | 204K | **3.7M** | — | — | **18x** |
420
+ | array 50 × transform (zero-capture) | 25K | 41K | **963K** | — | — | **24x** |
421
+ | object with captured transform | 1.3M | 6.1M | **15.2M** | — | — | 2.5x |
422
+ | object with captured refine (cross-field) | 1.6M | 2.5M | **13.7M** | — | — | 5.5x |
423
+ | object with superRefine (cross-field) | 1.5M | 2.3M | **11.7M** | — | — | 5.0x |
424
+
425
+ _ops/s, higher is better. `vitest bench` on an Apple M4 Max (zod 4.3.6, zod v3 3.23.8, typia 12, ajv 8),
426
+ best of two runs. The harness costs ~55 ns per iteration, so the fastest rows sit at that floor and gaps
427
+ between the AOT columns there are noise, not real._
428
+
429
+ Nested objects, arrays and recursive types gain the most. Rejection is fast because a failed
430
+ `safeParse` defers building the error until `.error` is read.
857
431
 
858
432
  ```bash
859
433
  pnpm benchmark # run locally
@@ -861,24 +435,16 @@ pnpm benchmark # run locally
861
435
 
862
436
  ### Performance Architecture
863
437
 
864
- For eligible schemas, zod-compiler generates a **two-phase validator**:
865
-
866
- 1. **Fast Path** A single `&&` expression chain that validates the entire input with zero allocations. Valid input returns immediately.
867
- 2. **Slow Path** Error-collecting validation that only runs when the Fast Path fails.
868
-
869
- Additional optimizations: pre-compiled regex, per-type check ordering (a string's `.min()` before its format regex), discriminated-union cases that skip the now-redundant object-guard and discriminator re-check after `switch` dispatch, and auto-discrimination of plain `z.union`s of tagged objects into the same switch dispatch.
870
-
871
- **Cheapest-first check ordering.** A fast check is one `&&` chain, so accepting input runs every conjunct whatever the order — but a _rejection_ stops at the first false one. Object properties, tuple positions, intersection sides and union options are therefore emitted in estimated-cost order, cheap type guards ahead of regex formats and nested containers. Valid input is unaffected; deciding that input does _not_ match gets much cheaper, which is what `.is()` guards, `z.union` probing and every failed `safeParse` actually do. Measured per validator (no harness overhead) on a 3-option union of objects declaring `{email, kind, note}`: an input matching the last option 102 ns → 39 ns (2.6x), one matching none 102 ns → 6 ns (16x); a 6-property object guard rejecting a wrong-typed field 38 ns → 5 ns (8x).
872
-
873
- **Object construction.** Where a fresh object must be produced — `stripUnknownKeys` — it is emitted as one object literal rather than assembled key by key. V8 stamps a literal out of a cached boilerplate map in a single allocation; adding keys one at a time walks a transition chain and re-checks the map on every store, which measured 8.1x slower on a 20-key shape. Keys that can be absent (optionals) are appended after the literal, in shape order, under zod's own presence rule.
874
-
875
- **Local bindings for called helpers.** Lean mode imports its shared helpers from the plugin's runtime module, and a bundler leaves those as module-scope `var`s. V8 folds a _local_ binding into a constant callee and inlines straight through `helper.call(...)`; a mutable module-scope one it will not, so the record fast path's per-key `__zcHop.call(o, k)` became a generic call. Aliasing such helpers into the IIFE once fixes it: measured on a plugin-transformed, esbuild-bundled artifact, a 5-key record went 34.6 ns → 9.2 ns and a 20-key record 157.3 ns → 23.1 ns. Only helpers invoked through `.call` need this — a direct call to an imported function, and an imported RegExp receiver, are both unaffected.
876
-
877
- **One declaration per value.** Constant tables — an enum's `Set`, a strict shape's key list, a file's mime list — are keyed by their initializer, so a value list reached from both the fast check and the slow walk, or shared by sibling properties, is emitted once rather than per use. Worth 18-21% of the generated bytes on enum-heavy schemas, and 10% raw / 5% gzip across a realistic schema set.
878
-
879
- **Membership tests.** A strict object's unknown-key pass consults an object literal (`TABLE[k] === 1`) rather than a `Set`, whose `has` costs a flat ~4 ns hash probe per key however small the set — 3.7x (8 keys) to 4.5x (32 keys) faster over the same for-in, taking an 8-key strict object from 53 ns to 32 ns end to end. Enum values keep the existing split: an inlined `===` chain up to 5 values, one `Set` lookup above that.
438
+ An eligible schema compiles to a **fast path** — one `&&` chain validating the whole input with zero
439
+ allocations, reused by `.is()` and `parse()` — plus a **slow path** that collects errors, run only on
440
+ failure and deferred until `.error` is read. A `z.object()` strips, so it instead compiles to a single
441
+ pass that validates and rebuilds together, bailing on the first failure. That pass also covers the
442
+ idioms that reshape a value — array size checks, `.refine()`, `.default()`, `.trim()`, `.transform()` —
443
+ so one of them in a schema no longer costs it the whole single-pass parse.
880
444
 
881
- Run `npx zod-compiler check --json` to see which schemas qualify for Fast Path.
445
+ Regexes are pre-compiled with bounded repeats unrolled, checks run cheapest-first, discriminated unions
446
+ dispatch through a jump table (plain tagged unions are auto-discriminated into it), and oversized check
447
+ functions are split to stay within V8's optimizer budget.
882
448
 
883
449
  ## Development
884
450