Skip to content

Commit 0c483c5

Browse files
authored
docs: Zod 4.6 announcement post (#6546)
* docs: Zod 4.6 announcement post Follows the 4.5 skeleton: at-a-glance links, one section per feature, a Bug fixes section of ⚠️ entries, then the commit roll-up. z.properties() moves here from the 4.5 post. It shipped in 4.5 as an array of checks, which could assert but never narrow; #6536 made it a schema with a .properties() method on z.instanceof(), so the feature a reader would go looking for is the 4.6 one. The #5912 line stays in the 4.5 commit list, since that commit did ship in 4.5. Every before/after line in the post is checked against the published 4.5.4 and a main checkout, including the .options ordering, the emoji and fromJSONSchema cases, and the locale swap. The banner moves to 4.6, as it did for 4.5. The two perf sections carry prose numbers rather than charts; the machine has been above load 15 throughout, which is well over the threshold AGENTS.md sets for a publishable figure. * docs: add the .validate() section to the 4.6 post Depends on #6547. Drop this commit if that one does not land in 4.6. * docs: add the merged validate-methods commit to the 4.6 roll-up #6547 landed as 62e6624, so the list is 51 commits now. * docs: give the last at-a-glance bullet the em dash the others have * docs: cut the 4.6 post down to four features, and chart z.validate() Drops the @zod/mini section — the standalone package shipped with 4.5, not 4.6 — along with the per-schema memory table and the recursive-factory fix. The memory numbers are marginal enough not to carry a section, and a crash fix that breaks nothing is not something the post talks about. fromJSONSchema moves up out of Bug fixes: six keywords going from silently dropped to enforced and round-tripping is feature work, not a correction. Bug fixes now holds only the four entries that are conceivably breaking. z.properties() gains a standalone-schema example alongside the method and spread forms, and .validate() gets the two failure-path charts plus the benchmark behind them. Every sample and both inferred types are checked against a main checkout; the chart numbers reproduce within 10% on a loaded machine. * docs: split the valid-input range by compile mode The 0.9x-1.3x figure was the plain-schema range, printed directly under the compiled chart where it read as covering both. A compiled validate is 1.1x to 2.1x on valid input, not at parity: the assertOnly codegen skips building the output the parser would construct and discard. * docs: add the merged stack-test commit to the 4.6 roll-up * docs: flesh out the 4.6 post's outstanding notes Adds the standalone z.properties() example on a widely known class, an Error and a plain object through one schema, and replaces the URL sample with the Response case z.object() genuinely cannot express, in Zod and Zod Mini tabs. The compiled validate chart leads now and the uncompiled one sits in an accordion. fromJSONSchema lists its six keywords against the JSON Schema reference. Corrects two claims. Validate does not short-circuit: a counting refinement runs on every field in all four configurations, compiled or not, so the saving is the result object plus the assertOnly codegen skipping output construction. And the headline is 35x on a compiled schema, 6.3x without, rather than a single unqualified 30x. Also sweeps the sentences that opened with inline code, per the prose guide. * docs: drop the now-unused Callout import from the 4.6 post * docs: converge the properties section on Response, restore the validate prose The opening example used an Error and the method example a Response, so the section changed subject halfway through. Both are Response now: duck-typed first, instance-gated second. Backs out the rewriting of the validate section beyond the chart reorder it asked for. The 6.3x sentence and the closing async/Mini paragraph are restored to their original wording and their own paragraph; only the inline-code sentence opening stays changed. * docs: give the properties example an explicit status, and match the prose to the identity check The Response constructor was relying on the reader knowing the default status is 200. The sentence above the identity check still described methods surviving, which was written for a different example; it names the object identity the example actually proves. * docs: reconcile the hand-written 4.6 draft Takes the hand-written validate section: the compile framing leads, the uncompiled paragraph moves inside the accordion with its chart, and the closing names short-circuiting with the async note as a callout. The fromJSONSchema intro leads with the six keywords rather than the history. Short-circuiting is real, and only once compiled. Reading property access rather than refinements, a compiled validate touches the first failing key and stops while a compiled safeParse walks all of them; an uncompiled validate walks all of them too, because a custom refinement never reaches the compiled path. The sentence is scoped to compilation for that reason. * docs: reconcile the hand-written draft, and add #6544 to the roll-up Restores the hand-written wording throughout: the validate section's compile framing, the uncompiled paragraph inside the accordion, the short-circuit closing and the async note, and the fromJSONSchema intro. The only edit to that text is the is/it typo. Drops the "once compiled" scope from the short-circuit sentence, since #6544 landed and an uncompiled validate now settles on the first failure too. Verified by reading property access: a three-key object with every key wrong is touched once by validate and three times by safeParse. * docs: correct the ZodInstanceOf casing in the 4.6 post * docs: add the compiled-validate vs plain-safeParse chart, and a Player schema The two existing charts each compare validate against safeParse on equal footing. This adds the cross comparison — compiled z.validate() against an uncompiled .safeParse().success — which is the upgrade most callers are actually making. It is the more conservative number: on the failure path a compiled safeParse is itself slightly slower than a plain one, since the fast path fails and then falls back to the runtime path to build issues. Re-sliced from the same benchmark run as the other two charts, so all three share one sweep, one warmup and one axis. Neither of its series is affected by #6544, which changed only the runtime validate path. Also defines the Player schema the .validate() example was assuming, and evens the two z.properties() tab bodies to the same line count. * bench: interleave the cross-mode pair in validate-vs-safeparse The plain and compiled targets were each measured to completion, with independent best-of-15 minima. That is fine for the two same-mode ratios the harness was written for, but the blog post now also publishes a cross-mode one — compiled z.validate() against plain .safeParse().success — and nothing paired those two numbers. Whatever drifted between the plain block and the compiled block landed entirely on that ratio, which is the same trap as running all of revision A before all of revision B. All four calls for a case now alternate inside one round and share one iteration count, so every ratio the harness prints is paired. The JSON output keeps its shape, and the cross-mode table is printed rather than left for a generator to derive. * bench: format validate-vs-safeparse, and correct its header lint-staged's globs do not cover packages/bench, so the formatter never ran on the previous commit and CI caught it instead. The header also still claimed no table compares compiled against uncompiled, which the cross-mode table it now prints makes false. * docs: remeasure all three validate figures on the interleaved harness Regenerated from one 13-pass run on a quiet machine (load 5.4-6.6) against the merged branch, so the numbers reflect both #6544's early abort and the interleaved cross-mode timing. Every figure now shares one axis, and the per-cell estimate is the minimum across passes, taken until no drawn cell improved by more than 1% in the last two. The headline moves: compiled 35.6x -> 38.1x, cross-mode 31.7x -> 36.1x. The uncompiled figure barely moves at 6.3x -> 6.4x, because early abort saves work in proportion to what follows the first failure and these eight fixtures fail late or are leaves; the 20-key object, which no chart draws, halves. * docs: drop the cross-mode chart from the 4.6 post It was meant to show what adopting z.compile() and .validate() together buys, but it restates the chart above it: compiling does not speed up a failing safeParse at all. The compiled parser is a validator and cannot build a ZodIssue[], so on invalid input it returns INVALID and the wrapper falls back to the full runtime parse anyway — 3-13% slower than never compiling. The two charts' gray bars are therefore the same measurement within 10%, and the second one earned no space. Also restores "Both charts measure the failure path" and removes the three now-unreferenced assets. The harness keeps printing the cross-mode table; that comparison is still worth having in the benchmark, just not in the post. * docs: cover the JSON Schema check fold and the lazy metadata members Two user-visible changes landed since the post was written and both belong in the breaking-ish list: the converter now folds def.checks as a conjunction, so a format check chained after .min()/.max() stops widening the emitted bounds, and the eight classic metadata members derive from the checks instead of being written onto every instance, so they are not own enumerable keys until first read. Both before/after blocks were run against v4.5.4 and against this branch rather than taken from the PR bodies. Roll-up goes to 61 commits. * docs: de-slop the 4.6 bug-fix prose A review pass against the copy guide's reads-as-AI fingerprints, applied only to the agent-written entries. The recurring shapes were the pre-announced move ("Two consequences."), the cleft ("What changed is enumeration:"), the colon-then-significance join, and the "X rather than Y" antithesis. The emoji entry also gains the actual mechanism: the Unicode property is Emoji_Component, and the lookahead demands a pictograph, regional indicator or keycap, per the comment on regexes.ts. Colin's own lines are untouched — the short-circuit paragraph, the z.properties() intro, the z.compile() sentence and the accordion's result-object explanation. * docs: move z.validate() out of the 4.5 post and lead 4.6 with it The 4.5 post loses its z.validate() section, its at-a-glance bullet, and the sentence pointing the AssertLoose chart at the function. That chart measures the assertLoose category and stands on its own caption, so it stays. The 4.6 post presents the whole feature as new: the standalone function first, carrying the 4.5 section's prose, then the Zod Classic method. The section moves ahead of z.properties(), the at-a-glance list and the frontmatter description follow the new order, and the anchor becomes #zvalidate to match the heading. * docs: correct the metadata example's keys and drop the value-parity claim Two errors in the section as written. The Object.keys() output omitted def and type, which are enumerable own properties in both versions, so the 4.6 side read as an empty array when it is ["def", "type"]. And "reading a member gives the same value as before" is false for an order-dependent chain: the getters read the same fold the converter does, so z.string().min(8).length(5).minLength goes from 5 to 8. That correction is worth stating rather than eliding, so it gets its own example. Both sides re-run against v4.5.4 and this branch. The enum and emoji blocks were checked the same way and are correct as they stand. * docs: drop the 4.5-era PR link from the 4.6 validate section The standalone paragraph cited #6471, which the 4.5 roll-up still credits, so the 4.6 section pointed at a PR from the previous cycle. The method paragraph keeps #6547, which is what landed here. * docs: remeasure both validate charts after the compile perf work #6567 made finalizeIssue and the compiled record walk cheaper, and both sit on the failing-parse path these charts measure, so the published ratios were against superseded code. The gray safeParse bars shrink and the ratios come down with them: compiled 38.1x -> 34.9x, uncompiled 6.4x -> 5.9x. Twelve passes on a quiet machine, per-cell minimum, taken until no drawn cell improved by more than 1% in the last two. Prose, both alt texts and the at-a-glance bullet follow. * docs: cover the CommonJS seal and the standalone mini package in the 4.6 post #6564 is a 3x speedup for every require("zod") consumer and had only a line in the commit roll-up. The section states the mechanism (TypeScript compiles a re-export to a getter, so V8 could not inline through it), and pins the ratio to a compiled schema, which is what was measured — on a plain schema the fixed ~20ns lookup is a much smaller share of the call, so the same claim would not hold. @zod/mini shipped in 4.5.3 and has never appeared in a post, so it gets an at-a-glance line pointing at the docs page. Also adds 277613a, the one commit on main the roll-up was missing, and moves the count to 62. * docs: date the @zod/mini line to the 4.5 series it shipped with The at-a-glance list is what 4.6 adds, and the standalone package is not one of them: #6491 merged on 2026-08-29 and the backfill published 4.5.0 through 4.5.4 in one batch seven minutes later. The line stays, because the package has never been announced anywhere, but it now says which series it belongs to. * docs: add the z.iban() section to the 4.6 post * docs: cover the recursive-schema retention fix and refresh the roll-up #6572 is the most user-visible thing in this release and had no section: a recursive schema pinned its last parse, and the reporter hit an 8 GiB OOM on a lint run that completed on 4.4.3. Reproduced both sides with the WeakRef probe from the regression test — 4.4.3 collects the parsed input, 4.5.4 retains it. The section states the ~6% cost on recursive parses alongside the retention win, since that is the trade the fix makes. Roll-up picks up the five commits that landed since, including z.iban() and the compile indent fix, and the count moves to 67. * docs: cover the email regex change and pick up the two newest commits #6573 leaves what z.email() accepts untouched, but three things around it move, and two are public surface: z.regexes.email loses its capture groups and the toJSONSchema pattern loses its lookahead, which is what let non-ECMAScript validators reject it. The third is a fix — the old lookahead was not scoped to its own segment, so a template literal embedding an email applied the no-consecutive-dots rule to the whole string. Verified rather than quoted: the two forms agree on all 111110 strings up to length 5 over the ten character classes involved, and the speedup on valid addresses measured 1.70x and 2.06x here at load 3.7, against the PR's 1.76x and 2.30x. The post says "roughly twice as fast", which both runs support. Roll-up also picks up the sponsor reconciliation; count moves to 69. * docs: name issue.pattern among the email surfaces that change A failed z.email() carries the regex source in issue.pattern, so the entry's "three other places" was an exhaustive count that missed one. Verified against 4.5.4: the issue has an own pattern key holding the full source. Dropped the count rather than raising it, since .def.pattern is arguably a fourth and the number adds nothing. * docs: call the feature .validate() in headings, labels and both charts The section heading, the at-a-glance entry and the chart bar labels said z.validate() while the series they were compared against said .safeParse().success, so the pair read inconsistently. Both figures are regenerated from the same 12-pass run — every number is unchanged, only the label moved — and the heading now reads ".validate() vs .safeParse().success". The anchor moves with the heading, from #zvalidate to #validate, and the at-a-glance link follows it. Nothing else in the docs tree pointed at the old one. Three references stay as z.validate() on purpose: the two calls in the section's own example, which teach the standalone form that Zod Mini and Zod Core use, and the CommonJS section, whose whole claim is about reading the callee off the namespace — the method form is not a namespace read and does not get the 3x. * docs: cover z.withParser, refresh the roll-up, and date the post to the release z.withParser landed in #6575 after the rest of the post was written. It is the installer half of compile() on its own, for a build-time or native compiler where new Function is unavailable, and it has no reference page anywhere yet — every other feature in this post has at least one, so the section is currently its only documentation. Roll-up picks up the three commits since, and the count moves to 72. The 4.6.0 version bump is deliberately excluded: the 4.5 roll-up lists no version commit either. Date moves from 2026-09-01 to the day the release was cut, matching how the 4.5 post's date tracks its npm publish. * docs: make the z.withParser example return fresh output The example handed the caller's object straight back after a type guard, which is not what the schema does: z.object() strips unknown keys, so Fast.parse({username, xp, extra}) kept extra where Player.parse() drops it. Verified against 4.6.0 source — as written it returned {"username","xp","extra"}, the schema returns {"username","xp"}. The parser owns the whole result, so the example now rebuilds the object and the prose says why. Caught by pullfrog.
1 parent a00c3f3 commit 0c483c5

10 files changed

Lines changed: 685 additions & 31 deletions
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import * as z from "zod";
2+
import { ZodCompileUnsupportedError, compile } from "zod/v4/core";
3+
4+
// z.validate(schema, input) vs schema.safeParse(input).success, across schema shapes, on valid and invalid input.
5+
//
6+
// Three tables. The first two answer what a caller holding one schema gains by asking for a boolean instead of a result: the same pair of calls on a plain schema, and on that schema passed through z.compile(). The third is the cross-mode upgrade — compiled validate against plain safeParse — which is what a caller adopting both at once gains.
7+
//
8+
// Methodology follows compile-matrix.ts: absolute ops/sec drifts by tens of percent between runs, so all four calls are measured *interleaved* inside one round and the best of N rounds is kept. That is what makes the cross-mode ratio legitimate; measuring the plain schema to completion and then the compiled one would put whatever drifted between those two blocks straight onto it. safeParse allocates a result object per call while validate allocates nothing, so a time-boxed loop would sample whatever the collector is doing — the harness uses a fixed iteration count with gc() between samples instead. The failing result's ZodError is not part of that: failure() builds it lazily behind a getter, so reading only .success never constructs one.
9+
10+
interface Case {
11+
name: string;
12+
schema: z.ZodType;
13+
valid: unknown;
14+
invalid: unknown;
15+
}
16+
17+
const cases: Case[] = [];
18+
const add = (name: string, schema: z.ZodType, valid: unknown, invalid: unknown) =>
19+
cases.push({ name, schema, valid, invalid });
20+
21+
add("z.string()", z.string(), "hello world", 42);
22+
add("z.number()", z.number(), 42.5, "42.5");
23+
add("z.boolean()", z.boolean(), true, "true");
24+
add("z.string().email()", z.email(), "user@example.com", "not-an-email");
25+
26+
const flat5 = { a: "x", b: 1, c: true, d: "y", e: 2 };
27+
add(
28+
"z.object(), 5 keys",
29+
z.object({ a: z.string(), b: z.number(), c: z.boolean(), d: z.string(), e: z.number() }),
30+
flat5,
31+
{ ...flat5, c: "not a boolean" }
32+
);
33+
34+
const wide20 = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`k${i}`, "v"]));
35+
add(
36+
"z.object(), 20 keys",
37+
z.object(Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`k${i}`, z.string()]))) as z.ZodType,
38+
wide20,
39+
{ ...wide20, k9: 9 }
40+
);
41+
42+
const moltarValid = {
43+
number: 1,
44+
negNumber: -1,
45+
maxNumber: Number.MAX_VALUE,
46+
string: "string",
47+
longString: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
48+
boolean: true,
49+
deeplyNested: { foo: "bar", num: 1, bool: false },
50+
};
51+
add(
52+
"nested object",
53+
z.object({
54+
number: z.number(),
55+
negNumber: z.number(),
56+
maxNumber: z.number(),
57+
string: z.string(),
58+
longString: z.string(),
59+
boolean: z.boolean(),
60+
deeplyNested: z.object({ foo: z.string(), num: z.number(), bool: z.boolean() }),
61+
}),
62+
moltarValid,
63+
{ ...moltarValid, deeplyNested: { ...moltarValid.deeplyNested, num: "1" } }
64+
);
65+
66+
add(
67+
"z.array(z.string()), 10",
68+
z.array(z.string()),
69+
Array.from({ length: 10 }, (_, i) => `s${i}`),
70+
Array.from({ length: 10 }, (_, i) => (i === 6 ? 6 : `s${i}`))
71+
);
72+
add(
73+
"z.array(z.object()), 10",
74+
z.array(z.object({ id: z.number(), name: z.string() })),
75+
Array.from({ length: 10 }, (_, i) => ({ id: i, name: `n${i}` })),
76+
Array.from({ length: 10 }, (_, i) => ({ id: i, name: i === 6 ? 6 : `n${i}` }))
77+
);
78+
add("z.tuple() of 3", z.tuple([z.string(), z.number(), z.boolean()]), ["a", 1, true], ["a", 1, "true"]);
79+
add(
80+
"z.union() of 3 objects",
81+
z.union([z.object({ a: z.string() }), z.object({ b: z.number() }), z.object({ c: z.boolean() })]),
82+
{ c: true },
83+
{ c: "true" }
84+
);
85+
add(
86+
"z.discriminatedUnion() of 3",
87+
z.discriminatedUnion("kind", [
88+
z.object({ kind: z.literal("k0"), v: z.number() }),
89+
z.object({ kind: z.literal("k1"), v: z.number() }),
90+
z.object({ kind: z.literal("k2"), v: z.number() }),
91+
]),
92+
{ kind: "k2", v: 1 },
93+
{ kind: "k2", v: "1" }
94+
);
95+
96+
// ---------------------------------------------------------------------------
97+
98+
const collect = typeof (globalThis as any).gc === "function" ? (globalThis as any).gc : () => {};
99+
const HAS_GC = typeof (globalThis as any).gc === "function";
100+
101+
const ROUNDS = 15;
102+
103+
// Consumed by every timed call and printed at the end. Without this V8 sees the result is dead and eliminates the call outright.
104+
let sink = 0;
105+
let escaped: unknown;
106+
107+
function timed(fn: () => void, iters: number): number {
108+
collect();
109+
const start = process.hrtime.bigint();
110+
for (let i = 0; i < iters; i++) fn();
111+
return Number(process.hrtime.bigint() - start) / 1e6; // ms
112+
}
113+
114+
/** Iterations that put one measurement near ~40ms, so a round is short but not noise. */
115+
function calibrate(fn: () => void): number {
116+
let iters = 64;
117+
for (;;) {
118+
const start = process.hrtime.bigint();
119+
for (let i = 0; i < iters; i++) fn();
120+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
121+
if (ms > 25 || iters > 4_000_000) return iters;
122+
iters = Math.max(iters * 2, Math.ceil((iters * 40) / Math.max(ms, 0.05)));
123+
}
124+
}
125+
126+
interface Measurement {
127+
safeParseNs: number;
128+
validateNs: number;
129+
}
130+
131+
interface Pair {
132+
plain: Measurement;
133+
compiled: Measurement | null;
134+
}
135+
136+
// all four calls alternate inside a round and share one iteration count, so every ratio the harness prints is paired
137+
function measure(plain: z.ZodType, compiled: z.ZodType | null, input: unknown): Pair {
138+
// Feed the input through an array load. Passed as a constant the whole call is loop-invariant and V8 hoists it out of the timing loop.
139+
const pool = Array.from({ length: 64 }, () => input);
140+
let idx = 0;
141+
const safeParseOn = (s: z.ZodType) => () => {
142+
const r = s.safeParse(pool[idx++ & 63]);
143+
sink += r.success ? 1 : 0;
144+
escaped = r;
145+
};
146+
const validateOn = (s: z.ZodType) => () => {
147+
const ok = z.validate(s, pool[idx++ & 63]);
148+
sink += ok ? 1 : 0;
149+
escaped = ok;
150+
};
151+
152+
const ops = [safeParseOn(plain), validateOn(plain)];
153+
if (compiled) ops.push(safeParseOn(compiled), validateOn(compiled));
154+
155+
// the plain safeParse is the slowest of the four, so calibrating on it keeps every block at or under the ~40ms target
156+
const iters = calibrate(ops[0]);
157+
for (let i = 0; i < 3; i++) for (const op of ops) op();
158+
159+
const best = ops.map(() => Number.POSITIVE_INFINITY);
160+
for (let r = 0; r < ROUNDS; r++)
161+
for (let i = 0; i < ops.length; i++) best[i] = Math.min(best[i], timed(ops[i], iters));
162+
163+
const ns = (i: number) => (best[i] * 1e6) / iters;
164+
return {
165+
plain: { safeParseNs: ns(0), validateNs: ns(1) },
166+
compiled: compiled ? { safeParseNs: ns(2), validateNs: ns(3) } : null,
167+
};
168+
}
169+
170+
interface Row {
171+
name: string;
172+
compiled: boolean;
173+
valid: Measurement;
174+
invalid: Measurement;
175+
}
176+
177+
const rows: Row[] = [];
178+
const problems: string[] = [];
179+
180+
interface Target {
181+
name: string;
182+
compiled: boolean;
183+
schema: z.ZodType;
184+
valid: unknown;
185+
invalid: unknown;
186+
}
187+
188+
interface CasePair {
189+
name: string;
190+
plain: z.ZodType;
191+
compiled: z.ZodType | null;
192+
valid: unknown;
193+
invalid: unknown;
194+
}
195+
196+
const targets: Target[] = [];
197+
const pairs: CasePair[] = [];
198+
for (const c of cases) {
199+
if (c.schema.safeParse(c.valid).success !== true) problems.push(`${c.name}: "valid" input does not parse`);
200+
if (c.schema.safeParse(c.invalid).success !== false) problems.push(`${c.name}: "invalid" input parses`);
201+
if (z.validate(c.schema, c.valid) !== true) problems.push(`${c.name}: validate rejects valid input`);
202+
if (z.validate(c.schema, c.invalid) !== false) problems.push(`${c.name}: validate accepts invalid input`);
203+
targets.push({ name: c.name, compiled: false, schema: c.schema, valid: c.valid, invalid: c.invalid });
204+
205+
// strict: a silent uncompiled fallback would publish a "compiled" row that is really a second uncompiled measurement
206+
let compiled: z.ZodType | null = null;
207+
try {
208+
compiled = compile(c.schema, { strict: true });
209+
} catch (err) {
210+
problems.push(
211+
`${c.name}: did not compile (${err instanceof ZodCompileUnsupportedError ? "unsupported" : (err as Error).name})`
212+
);
213+
}
214+
if (compiled) {
215+
if (z.validate(compiled, c.valid) !== true || z.validate(compiled, c.invalid) !== false)
216+
problems.push(`${c.name}: compiled validate disagrees with the runtime`);
217+
targets.push({ name: c.name, compiled: true, schema: compiled, valid: c.valid, invalid: c.invalid });
218+
}
219+
pairs.push({ name: c.name, plain: c.schema, compiled, valid: c.valid, invalid: c.invalid });
220+
}
221+
222+
// Every case shares one safeParse/validate call site inside measure(), so those inline caches go megamorphic as the sweep proceeds and a case measured first would see a cleaner cache than one measured last. Warm every target before timing any, so each is measured against the same polluted-cache state.
223+
for (let i = 0; i < 200; i++) {
224+
for (const t of targets) {
225+
for (const input of [t.valid, t.invalid]) {
226+
sink += t.schema.safeParse(input).success ? 1 : 0;
227+
sink += z.validate(t.schema, input) ? 1 : 0;
228+
}
229+
}
230+
}
231+
232+
for (const p of pairs) {
233+
const valid = measure(p.plain, p.compiled, p.valid);
234+
const invalid = measure(p.plain, p.compiled, p.invalid);
235+
rows.push({ name: p.name, compiled: false, valid: valid.plain, invalid: invalid.plain });
236+
if (valid.compiled && invalid.compiled)
237+
rows.push({ name: p.name, compiled: true, valid: valid.compiled, invalid: invalid.compiled });
238+
}
239+
240+
const fmtNs = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}µs` : `${n.toFixed(0)}ns`);
241+
const pad = (s: string, n: number) => s.padEnd(n);
242+
const padL = (s: string, n: number) => s.padStart(n);
243+
244+
for (const mode of [false, true]) {
245+
const subset = rows.filter((r) => r.compiled === mode);
246+
if (!subset.length) continue;
247+
console.log();
248+
console.log(
249+
`${mode ? "compiled with z.compile()" : "plain schemas"} — z.validate vs safeParse().success, best of ${ROUNDS} interleaved rounds${HAS_GC ? "" : " (no gc: run with --expose-gc)"}`
250+
);
251+
console.log();
252+
console.log(
253+
`${pad("schema", 26)}${padL("valid sp", 11)}${padL("valid v", 10)}${padL("x", 8)}${padL("invalid sp", 12)}${padL("invalid v", 11)}${padL("x", 8)}`
254+
);
255+
console.log("-".repeat(86));
256+
for (const r of subset) {
257+
console.log(
258+
`${pad(r.name, 26)}` +
259+
`${padL(fmtNs(r.valid.safeParseNs), 11)}${padL(fmtNs(r.valid.validateNs), 10)}${padL(`${(r.valid.safeParseNs / r.valid.validateNs).toFixed(2)}x`, 8)}` +
260+
`${padL(fmtNs(r.invalid.safeParseNs), 12)}${padL(fmtNs(r.invalid.validateNs), 11)}${padL(`${(r.invalid.safeParseNs / r.invalid.validateNs).toFixed(2)}x`, 8)}`
261+
);
262+
}
263+
console.log("-".repeat(86));
264+
for (const [label, key] of [
265+
["valid input ", "valid"],
266+
["invalid input", "invalid"],
267+
] as const) {
268+
const ratios = subset.map((r) => r[key].safeParseNs / r[key].validateNs).sort((a, b) => a - b);
269+
console.log(
270+
`${label}: median ${ratios[Math.floor(ratios.length / 2)].toFixed(1)}x, range ${ratios[0].toFixed(1)}x-${ratios[ratios.length - 1].toFixed(1)}x`
271+
);
272+
}
273+
}
274+
275+
// The cross-mode table: what a caller gains by adopting both at once. Its two columns come from the same interleaved rounds as the two tables above, so this ratio is paired like theirs.
276+
{
277+
const cross = rows
278+
.filter((r) => !r.compiled)
279+
.map((r) => ({ name: r.name, compiled: rows.find((o) => o.name === r.name && o.compiled) }))
280+
.filter((r): r is { name: string; compiled: Row } => !!r.compiled);
281+
if (cross.length) {
282+
console.log();
283+
console.log(
284+
`compiled z.validate vs plain safeParse().success — the cross-mode upgrade, best of ${ROUNDS} interleaved rounds`
285+
);
286+
console.log();
287+
console.log(`${pad("schema", 26)}${padL("plain sp", 11)}${padL("comp v", 10)}${padL("x", 8)}`);
288+
console.log("-".repeat(55));
289+
for (const c of cross) {
290+
const plainSp = rows.find((o) => o.name === c.name && !o.compiled)!.invalid.safeParseNs;
291+
const compV = c.compiled.invalid.validateNs;
292+
console.log(
293+
`${pad(c.name, 26)}${padL(fmtNs(plainSp), 11)}${padL(fmtNs(compV), 10)}${padL(`${(plainSp / compV).toFixed(2)}x`, 8)}`
294+
);
295+
}
296+
console.log("-".repeat(55));
297+
const ratios = cross
298+
.map(
299+
(c) => rows.find((o) => o.name === c.name && !o.compiled)!.invalid.safeParseNs / c.compiled.invalid.validateNs
300+
)
301+
.sort((a, b) => a - b);
302+
console.log(
303+
`invalid input: median ${ratios[Math.floor(ratios.length / 2)].toFixed(1)}x, range ${ratios[0].toFixed(1)}x-${ratios[ratios.length - 1].toFixed(1)}x`
304+
);
305+
}
306+
}
307+
308+
if (problems.length) {
309+
console.log();
310+
console.log("PROBLEMS:");
311+
for (const p of problems) console.log(` ${p}`);
312+
}
313+
console.log();
314+
console.log(`sink ${sink} ${typeof escaped}`);
315+
console.log(`JSON ${JSON.stringify(rows)}`);

‎packages/docs/app/layout.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ export default function Layout({ children }: { children: ReactNode }) {
2727
/>
2828
</head>
2929
<body className="flex flex-col min-h-screen">
30-
<Banner id="zod45">
31-
💎 Zod 4.5 is out! <span>&nbsp;</span>
32-
<a className="underline" href="/blog/zod-4-5">
30+
<Banner id="zod46">
31+
💎 Zod 4.6 is out! <span>&nbsp;</span>
32+
<a className="underline" href="/blog/zod-4-6">
3333
Read the announcement.
3434
</a>
3535
</Banner>

‎packages/docs/content/blog/zod-4-5.mdx‎

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ At a glance:
1919

2020
- [`z.compile()`](#zcompile) — the flagship feature of Zod 4.5
2121
- [`z.creditCard()`](#zcreditcard) — 12–19 digits plus Luhn checksum
22-
- [`z.properties()`](#zproperties) — the multi-property counterpart to `z.property()`
2322
- [`z.deepPartial()`](#zdeeppartial)/[`.exactPartial()`](#exactpartial)
24-
- [`z.validate(): boolean`](#zvalidate) — a fast-path to verify input validity without a full parse (up to 16x faster on invalid data)
2523
- [9x reduction in memory footprint](#9x-reduction-in-schema-memory-footprint)
2624
- [New locales](https://zod.dev/error-customization#locales): Bengali (`bn`), Central Kurdish (`ckb`), Hindi (`hi`), Kannada (`kn`), Norwegian Nynorsk (`nn`), Brazilian Portuguese (`pt-BR`), Slovak (`sk`), Turkmen (`tk`)
2725

@@ -67,7 +65,7 @@ Below are the Moltar benchmark results comparing Zod (compiled and uncompiled) a
6765
caption={<>Throughput on the moltar benchmark fixture (parseSafe: returns a new object with unknown keys stripped) — higher is better (<a href="https://github.com/moltar/typescript-runtime-type-benchmarks/pull/2329">benchmark</a>)</>}
6866
/>
6967

70-
And the equivalent results for the Moltar AssertLoose bench. Tested against the new `z.validate(schema, input)` function (detailed later in the post).
68+
And the equivalent results for the Moltar AssertLoose bench.
7169

7270
<ThemedImage
7371
lightSrc="/blog/moltar-assertLoose-light.svg"
@@ -127,22 +125,6 @@ z.creditCard().parse("4111 1111 1111 1111"); // ✅
127125
z.creditCard().parse("4111 1111 1111 1112"); // ❌ bad checksum
128126
```
129127

130-
## `z.properties()`
131-
132-
The multi-property counterpart to `z.property()`. ([#5912](https://github.com/colinhacks/zod/pull/5912))
133-
134-
```ts
135-
const okResponse = z.instanceof(Response).check(
136-
...z.properties({
137-
status: z.number().min(200).max(299),
138-
redirected: z.literal(false),
139-
})
140-
);
141-
142-
okResponse.parse(new Response("ok")); // ✅
143-
okResponse.parse(new Response("", { status: 404 })); // ❌ status
144-
```
145-
146128
## `z.deepPartial()`
147129

148130
Back in functional form after being removed as a method in Zod 4. ([#5928](https://github.com/colinhacks/zod/pull/5928))
@@ -176,15 +158,6 @@ PartialRecipe.parse({ title: undefined }); // ❌
176158

177159
In Zod Mini it's a top-level function: `z.exactPartial(Recipe)`.
178160

179-
## `z.validate()`
180-
181-
Standalone boolean validation, in Zod, Zod Mini, and Zod Core. It answers "is this input valid?" without constructing a `ZodError`, which makes rejection cheap: on invalid input it is up to 16x faster than `.safeParse().success`. The return type is a guard on the schema's input type, and `z.validateAsync()` covers schemas with async refinements. ([#6471](https://github.com/colinhacks/zod/pull/6471))
182-
183-
```ts
184-
z.validate(z.string(), "hi"); // true
185-
z.validate(z.string(), 42); // false
186-
```
187-
188161
## `z.input()` / `z.output()`
189162

190163
Project a schema onto its input or output side. Useful for validating the two halves of a codec independently. ([#5928](https://github.com/colinhacks/zod/pull/5928))

0 commit comments

Comments
 (0)