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.
- package/README.md +154 -588
- package/dist/cli/commands/generate.d.ts +0 -2
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +0 -2
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/watch.d.ts +0 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/commands/watch.js +0 -2
- package/dist/cli/commands/watch.js.map +1 -1
- package/dist/cli/index.js +0 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/core/codegen/build-path.d.ts +48 -0
- package/dist/core/codegen/build-path.d.ts.map +1 -0
- package/dist/core/codegen/build-path.js +642 -0
- package/dist/core/codegen/build-path.js.map +1 -0
- package/dist/core/codegen/context.d.ts +106 -9
- package/dist/core/codegen/context.d.ts.map +1 -1
- package/dist/core/codegen/context.js +89 -39
- package/dist/core/codegen/context.js.map +1 -1
- package/dist/core/codegen/dedupe.d.ts +7 -0
- package/dist/core/codegen/dedupe.d.ts.map +1 -1
- package/dist/core/codegen/dedupe.js +7 -0
- package/dist/core/codegen/dedupe.js.map +1 -1
- package/dist/core/codegen/fast-path.d.ts.map +1 -1
- package/dist/core/codegen/fast-path.js +11 -5
- package/dist/core/codegen/fast-path.js.map +1 -1
- package/dist/core/codegen/index.d.ts.map +1 -1
- package/dist/core/codegen/index.js +95 -5
- package/dist/core/codegen/index.js.map +1 -1
- package/dist/core/codegen/issue-decls.d.ts +40 -0
- package/dist/core/codegen/issue-decls.d.ts.map +1 -1
- package/dist/core/codegen/issue-decls.js +70 -0
- package/dist/core/codegen/issue-decls.js.map +1 -1
- package/dist/core/codegen/regex-unroll.d.ts +36 -0
- package/dist/core/codegen/regex-unroll.d.ts.map +1 -0
- package/dist/core/codegen/regex-unroll.js +231 -0
- package/dist/core/codegen/regex-unroll.js.map +1 -0
- package/dist/core/codegen/schemas/array.d.ts.map +1 -1
- package/dist/core/codegen/schemas/array.js +22 -5
- package/dist/core/codegen/schemas/array.js.map +1 -1
- package/dist/core/codegen/schemas/default.d.ts +15 -0
- package/dist/core/codegen/schemas/default.d.ts.map +1 -1
- package/dist/core/codegen/schemas/default.js +26 -5
- package/dist/core/codegen/schemas/default.js.map +1 -1
- package/dist/core/codegen/schemas/discriminated-union.d.ts.map +1 -1
- package/dist/core/codegen/schemas/discriminated-union.js +3 -3
- package/dist/core/codegen/schemas/discriminated-union.js.map +1 -1
- package/dist/core/codegen/schemas/map.d.ts +17 -0
- package/dist/core/codegen/schemas/map.d.ts.map +1 -1
- package/dist/core/codegen/schemas/map.js +62 -7
- package/dist/core/codegen/schemas/map.js.map +1 -1
- package/dist/core/codegen/schemas/nullable.d.ts.map +1 -1
- package/dist/core/codegen/schemas/nullable.js +2 -4
- package/dist/core/codegen/schemas/nullable.js.map +1 -1
- package/dist/core/codegen/schemas/number.d.ts.map +1 -1
- package/dist/core/codegen/schemas/number.js +25 -0
- package/dist/core/codegen/schemas/number.js.map +1 -1
- package/dist/core/codegen/schemas/object.d.ts.map +1 -1
- package/dist/core/codegen/schemas/object.js +27 -18
- package/dist/core/codegen/schemas/object.js.map +1 -1
- package/dist/core/codegen/schemas/optional.d.ts +10 -0
- package/dist/core/codegen/schemas/optional.d.ts.map +1 -1
- package/dist/core/codegen/schemas/optional.js +4 -6
- package/dist/core/codegen/schemas/optional.js.map +1 -1
- package/dist/core/codegen/schemas/record.d.ts.map +1 -1
- package/dist/core/codegen/schemas/record.js +11 -5
- package/dist/core/codegen/schemas/record.js.map +1 -1
- package/dist/core/codegen/schemas/set.d.ts.map +1 -1
- package/dist/core/codegen/schemas/set.js +11 -7
- package/dist/core/codegen/schemas/set.js.map +1 -1
- package/dist/core/codegen/schemas/string.d.ts +13 -2
- package/dist/core/codegen/schemas/string.d.ts.map +1 -1
- package/dist/core/codegen/schemas/string.js +68 -56
- package/dist/core/codegen/schemas/string.js.map +1 -1
- package/dist/core/codegen/schemas/template-literal.d.ts.map +1 -1
- package/dist/core/codegen/schemas/template-literal.js +9 -1
- package/dist/core/codegen/schemas/template-literal.js.map +1 -1
- package/dist/core/codegen/schemas/tuple.d.ts.map +1 -1
- package/dist/core/codegen/schemas/tuple.js +4 -3
- package/dist/core/codegen/schemas/tuple.js.map +1 -1
- package/dist/core/codegen/schemas/union.d.ts.map +1 -1
- package/dist/core/codegen/schemas/union.js +2 -1
- package/dist/core/codegen/schemas/union.js.map +1 -1
- package/dist/core/codegen/well-known-regex.d.ts +22 -4
- package/dist/core/codegen/well-known-regex.d.ts.map +1 -1
- package/dist/core/codegen/well-known-regex.js +27 -5
- package/dist/core/codegen/well-known-regex.js.map +1 -1
- package/dist/core/extract/extractors/default.d.ts.map +1 -1
- package/dist/core/extract/extractors/default.js +6 -1
- package/dist/core/extract/extractors/default.js.map +1 -1
- package/dist/core/extract/extractors/object.d.ts.map +1 -1
- package/dist/core/extract/extractors/object.js +11 -5
- package/dist/core/extract/extractors/object.js.map +1 -1
- package/dist/core/extract/index.d.ts +3 -3
- package/dist/core/extract/index.d.ts.map +1 -1
- package/dist/core/extract/index.js +2 -2
- package/dist/core/extract/index.js.map +1 -1
- package/dist/core/extract/registry.d.ts +2 -2
- package/dist/core/extract/registry.d.ts.map +1 -1
- package/dist/core/extract/registry.js +4 -5
- package/dist/core/extract/registry.js.map +1 -1
- package/dist/core/extract/types.d.ts +0 -11
- package/dist/core/extract/types.d.ts.map +1 -1
- package/dist/core/iife.d.ts.map +1 -1
- package/dist/core/iife.js +3 -2
- package/dist/core/iife.js.map +1 -1
- package/dist/core/pipeline.d.ts +0 -2
- package/dist/core/pipeline.d.ts.map +1 -1
- package/dist/core/pipeline.js +1 -3
- package/dist/core/pipeline.js.map +1 -1
- package/dist/core/types.d.ts +11 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/swc.d.ts.map +1 -1
- package/dist/swc.js +0 -1
- package/dist/swc.js.map +1 -1
- package/dist/unplugin/hoist-compile.d.ts +1 -1
- package/dist/unplugin/hoist-compile.d.ts.map +1 -1
- package/dist/unplugin/hoist-compile.js +4 -4
- package/dist/unplugin/hoist-compile.js.map +1 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +0 -3
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/transform.d.ts.map +1 -1
- package/dist/unplugin/transform.js +1 -2
- package/dist/unplugin/transform.js.map +1 -1
- package/dist/unplugin/types.d.ts +0 -23
- package/dist/unplugin/types.d.ts.map +1 -1
- package/dist/unplugin/types.js.map +1 -1
- package/dist/unplugin/virtual.d.ts.map +1 -1
- package/dist/unplugin/virtual.js +11 -7
- package/dist/unplugin/virtual.js.map +1 -1
- 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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
|
152
|
-
| `schemas`
|
|
153
|
-
| `include`
|
|
154
|
-
| `exclude`
|
|
155
|
-
| `output`
|
|
156
|
-
| `verbose`
|
|
157
|
-
| `hoist`
|
|
158
|
-
| `apply`
|
|
159
|
-
| `codegenMode`
|
|
160
|
-
| `
|
|
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
|
|
189
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
360
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
545
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
343
|
+
### Fully Compiled (2-45x faster)
|
|
702
344
|
|
|
703
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
|
783
|
-
|
|
|
784
|
-
|
|
|
785
|
-
|
|
|
786
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
818
|
-
| string (min/max) | 12.
|
|
819
|
-
| number (int+positive) |
|
|
820
|
-
| enum |
|
|
821
|
-
| bigint (min/max) |
|
|
822
|
-
| tuple [string, int, bool] | 5.
|
|
823
|
-
| record\<string, number\> | 3.2M | 2.
|
|
824
|
-
| set\<string\> (5 items) | 3.
|
|
825
|
-
| set\<string\> (20 items) | 1.3M |
|
|
826
|
-
| map\<string, number\> (5 entries) | 2.0M | 1.
|
|
827
|
-
| map\<string, number\> (20 entries) |
|
|
828
|
-
| pipe (non-transform) | 8.7M | 5.
|
|
829
|
-
| discriminatedUnion (3 variants) | 3.
|
|
830
|
-
| discriminatedUnion (8 variants, rotating) | 2.7M | 3.5M | **
|
|
831
|
-
| plain union of 8 tagged objects (auto-discrim.) |
|
|
832
|
-
| strict object (DB row) | 1.8M | 3.
|
|
833
|
-
| medium object (valid) | 1.
|
|
834
|
-
| medium object (
|
|
835
|
-
|
|
|
836
|
-
| large object (
|
|
837
|
-
|
|
|
838
|
-
| recursive tree (
|
|
839
|
-
|
|
|
840
|
-
| nested recursion (
|
|
841
|
-
|
|
|
842
|
-
|
|
|
843
|
-
|
|
|
844
|
-
|
|
|
845
|
-
| array
|
|
846
|
-
|
|
|
847
|
-
| object with captured
|
|
848
|
-
| object with
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
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
|
|