|
| 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)}`); |
0 commit comments