Skip to content

perf(v4): dedupe factory def boilerplate in core/api.ts (-2.3% tree-shaken) - #6348

Closed
zirkelc wants to merge 3 commits into
colinhacks:mainfrom
zirkelc:perf-dedupe-factories
Closed

perf(v4): dedupe factory def boilerplate in core/api.ts (-2.3% tree-shaken)#6348
zirkelc wants to merge 3 commits into
colinhacks:mainfrom
zirkelc:perf-dedupe-factories

Conversation

@zirkelc

@zirkelc zirkelc commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Context: bundle-size optimization campaign. This is one of four PRs extracted from a systematic bundle-size campaign on the v4 core, run as an automated research loop: 30 isolated experiments, 24 kept, 6 discarded. Every candidate change was

  • measured with a deterministic A/B size harness that materialises two git revisions of packages/zod/src via git archive and bundles seven fixed entry cases with esbuild (--bundle --minify --format=esm --target=es2020), recording minified and gzipped (zlib 9) bytes plus esbuild metafiles for attribution. The verification below leads with the standard consumer: import * as z from "zod" + z.object({ name: z.string(), age: z.number() }).safeParse(...) (70.9 kB min at baseline; namespace property accesses tree-shake under esbuild). Also reported: import { z }, which defeats tree-shaking entirely under esbuild (the exported z binding is a namespace object that escapes as a value, pinning the full classic surface, 327.7 kB min at baseline, no matter how few methods are used), and named imports (import { string }), the best-case shaking scenario;
  • judged against an exact-zero calibration: identical revisions on both sides produce 0-byte deltas on every case (esbuild output is deterministic), so every reported delta is exact and there is no noise floor to argue about. Minified bytes is the judged metric; gzip is reported alongside;
  • verified behaviour-preserving by the full test suite (339 files, 3,811 tests including tsc typecheck; snapshot-heavy, so error messages, def shapes, and JSON Schema output are pinned), green after every commit. Experiments that changed observable behaviour or regressed a tree-shaken case were discarded; e.g. sharing regex source strings was rejected because esbuild cannot tree-shake new RegExp with a non-literal argument and the shared string would have been pinned into every zod/mini consumer;
  • runtime-smoked where a change touched construction or error paths (packages/bench init and object-fail), with results inside run-to-run noise;
  • cross-checked at the end against the real build: cumulative deltas re-measured on zshy-built package output bundled consumer-style matched the source-level harness byte-for-byte.

The numbers below are fresh verification runs of this branch in isolation against main, first per commit (each against its parent), then the branch total. Negative = smaller.

Sibling PRs from the same campaign: #6349, #6350, #6351.

What this PR does

Dedupes the repeated def-literal boilerplate in the core/api.ts factory functions. Dozens of factories build near-identical def objects that the minifier cannot compress because property names are not mangled. Three commits, each preserving def key order and key presence byte-for-byte:

1. String-format schema factories (_sf/_sfa). 27 factories (_email, _uuid, _ipv6, the ISO family, ...) each spelled out { type: "string", format: X, check: "string_format", abort: false, ...normalizeParams(params) }. They now delegate to two small helpers; _sfa supplies the abort: false default, _sf is used by the ISO factories that never set abort, and per-factory extras (version: "v4", precision: null, ...) pass through a defaults argument in the original key position. Public signatures unchanged.

2. Check factories (_ck/_ckf). Same treatment for 17 check factories (_lt/_gte, _minLength-family, _regex/_includes/_startsWith/_endsWith, ...). Trailing fields still spread after normalizeParams, so user params cannot override value/inclusive/pattern, exactly as before.

3. Shared _defaultDef. The defaultValue-getter def literal (unwrap function values, shallowClone static ones) appeared five times across core/api.ts, classic/schemas.ts, and mini/schemas.ts; it is now built by one exported helper.

All three are construction-time only; no parse-path code is touched. The schema-init benchmark (pnpm bench init) was smoked around the round-1 factory change with results inside run-to-run noise.

Verification (this branch vs main)

Per case, minified bytes (gzip tracked alongside; judged on minified):

commit namespace import import * as z (standard) z-object import import { z } (full) named imports import { string }
1. _sf/_sfa string-format factories -1,409 (-1.99%) -1,465 -1,412
2. _ck/_ckf check factories -191 -227 -122
3. _defaultDef -58 -108 -58
branch total -1,658 (-2.34%) -1,800 (-0.55%) -1,592 (-2.78%)

Full suite green after every commit (3,847 tests + typecheck on current main). One trade-off to disclose: minimal zod/mini consumers retain the _ck helper once, +45 bytes on a 7.4 kB bundle (commit 2); classic consumers see no regression anywhere. Net source delta: 3 files, +111/-323.

Reproducing the numbers

The harness below is the one used for the verification runs. From a checkout of this repo:

  1. pnpm install

  2. Save the file below as sizecheck/compare.mts (the directory must live inside the repo so the materialised trees resolve node_modules; sizecheck/{a,b,meta}/ are created automatically and safe to delete).

  3. Fetch this PR's head ref and compare it against main:

    git fetch origin pull/6348/head:pr-branch
    pnpm tsx sizecheck/compare.mts main pr-branch

A run takes ~15s and prints per-case minified/gzip bytes for both sides with exact deltas; the namespace-import-classic row is the standard import * as z consumer, z-import-classic the import { z } style, named-import-classic the import { string } style. A control run with the same rev on both sides (pnpm tsx sizecheck/compare.mts main main) prints all-zero deltas — the pipeline is deterministic, so single runs are conclusive.

sizecheck/compare.mts — A/B bundle-size harness
/**
 * A/B bundle-size comparison between two git revisions of packages/zod.
 *
 * Usage: pnpm exec tsx sizecheck/compare.mts [revA] [revB]
 *   revA defaults to HEAD, revB defaults to WORK (the working tree).
 *
 * Each side is materialised under sizecheck/<side>/ via `git archive` (or a
 * straight copy for WORK), then a fixed set of entry files is bundled with
 * esbuild (--bundle --minify --format=esm, pinned target). Reports minified
 * and gzipped bytes per case plus deltas, and writes esbuild metafiles to
 * sizecheck/meta/.
 */
import { execSync } from "node:child_process";
import { gzipSync } from "node:zlib";
import * as fs from "node:fs";
import * as path from "node:path";
import { build } from "esbuild";

const root = path.resolve(import.meta.dirname, "..");
const work = path.join(root, "sizecheck");

const revA = process.argv[2] ?? "HEAD";
const revB = process.argv[3] ?? "WORK";

/** Entry cases. Files are written identically into each side's tree. */
const CASES: Array<{ name: string; code: string }> = [
  {
    name: "full-import-classic",
    code: `import * as z from "./packages/zod/src/index.ts";\nconsole.log(z);\n`,
  },
  {
    name: "full-import-mini",
    code: `import * as z from "./packages/zod/src/mini/index.ts";\nconsole.log(z);\n`,
  },
  {
    name: "named-import-mini",
    code: `import { string, minLength, safeParse } from "./packages/zod/src/mini/index.ts";\nconst schema = string().check(minLength(5));\nconsole.log(safeParse(schema, "hello"));\n`,
  },
  {
    name: "named-import-classic",
    code: `import { string } from "./packages/zod/src/index.ts";\nconst schema = string().min(5);\nconsole.log(schema.safeParse("hello"));\n`,
  },
  {
    name: "namespace-import-classic",
    code: `import * as z from "./packages/zod/src/index.ts";\nconst schema = z.object({ name: z.string(), age: z.number() });\nconsole.log(schema.safeParse({ name: "hello", age: 1 }));\n`,
  },
  {
    name: "z-import-classic",
    code: `import { z } from "./packages/zod/src/index.ts";\nconst schema = z.object({ name: z.string(), age: z.number() });\nconsole.log(schema.safeParse({ name: "hello", age: 1 }));\n`,
  },
  {
    name: "full-import-locales",
    code: `import * as locales from "./packages/zod/src/locales/index.ts";\nconsole.log(locales);\n`,
  },
];

function materialise(rev: string, dir: string): void {
  fs.rmSync(dir, { recursive: true, force: true });
  fs.mkdirSync(dir, { recursive: true });
  if (rev === "WORK") {
    fs.mkdirSync(path.join(dir, "packages/zod"), { recursive: true });
    fs.cpSync(path.join(root, "packages/zod/src"), path.join(dir, "packages/zod/src"), { recursive: true });
    fs.copyFileSync(path.join(root, "packages/zod/package.json"), path.join(dir, "packages/zod/package.json"));
  } else {
    execSync(`git archive ${rev} -- packages/zod/src packages/zod/package.json | tar -x -C ${JSON.stringify(dir)}`, {
      cwd: root,
      shell: "/bin/zsh",
    });
  }
  for (const c of CASES) {
    fs.writeFileSync(path.join(dir, `${c.name}.entry.ts`), c.code);
  }
}

interface Result {
  min: number;
  gzip: number;
}

async function measure(dir: string, side: string): Promise<Record<string, Result>> {
  const out: Record<string, Result> = {};
  for (const c of CASES) {
    const result = await build({
      entryPoints: [path.join(dir, `${c.name}.entry.ts`)],
      bundle: true,
      minify: true,
      format: "esm",
      target: "es2020",
      write: false,
      metafile: true,
      outfile: `${c.name}.js`,
      logLevel: "silent",
    });
    const contents = result.outputFiles[0].contents;
    out[c.name] = {
      min: contents.byteLength,
      gzip: gzipSync(contents, { level: 9 }).byteLength,
    };
    fs.mkdirSync(path.join(work, "meta"), { recursive: true });
    fs.writeFileSync(path.join(work, "meta", `${side}-${c.name}.json`), JSON.stringify(result.metafile));
  }
  return out;
}

function fmt(n: number): string {
  return n.toLocaleString("en-US");
}

const dirA = path.join(work, "a");
const dirB = path.join(work, "b");
materialise(revA, dirA);
materialise(revB, dirB);

const resA = await measure(dirA, "a");
const resB = await measure(dirB, "b");

console.log(`A = ${revA}, B = ${revB}\n`);
const pad = (s: string, n: number) => s.padStart(n);
console.log(
  "case".padEnd(16) +
    pad("A min", 10) +
    pad("B min", 10) +
    pad("Δ min", 9) +
    pad("Δ%", 8) +
    pad("A gz", 9) +
    pad("B gz", 9) +
    pad("Δ gz", 8)
);
let totMinA = 0;
let totMinB = 0;
let totGzA = 0;
let totGzB = 0;
for (const c of CASES) {
  const a = resA[c.name];
  const b = resB[c.name];
  totMinA += a.min;
  totMinB += b.min;
  totGzA += a.gzip;
  totGzB += b.gzip;
  const dMin = b.min - a.min;
  const dGz = b.gzip - a.gzip;
  const pct = a.min === 0 ? 0 : (dMin / a.min) * 100;
  console.log(
    c.name.padEnd(16) +
      pad(fmt(a.min), 10) +
      pad(fmt(b.min), 10) +
      pad((dMin >= 0 ? "+" : "") + fmt(dMin), 9) +
      pad(`${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`, 8) +
      pad(fmt(a.gzip), 9) +
      pad(fmt(b.gzip), 9) +
      pad((dGz >= 0 ? "+" : "") + fmt(dGz), 8)
  );
}
const dTotMin = totMinB - totMinA;
const dTotGz = totGzB - totGzA;
console.log(
  "TOTAL".padEnd(16) +
    pad(fmt(totMinA), 10) +
    pad(fmt(totMinB), 10) +
    pad((dTotMin >= 0 ? "+" : "") + fmt(dTotMin), 9) +
    pad(`${totMinA ? ((dTotMin / totMinA) * 100).toFixed(2) : "0.00"}%`, 8) +
    pad(fmt(totGzA), 9) +
    pad(fmt(totGzB), 9) +
    pad((dTotGz >= 0 ? "+" : "") + fmt(dTotGz), 8)
);

@colinhacks

Copy link
Copy Markdown
Owner

Closing this one. The headline is minified bytes, and that reduction doesn't survive compression. I re-measured the branch against its own merge-base: the ~1.6 kB minified drop on import * as z from "zod" lands at -36 bytes gzipped under terser, -14 under esbuild's minifier, and +26 under brotli. Minimal zod/mini bundles come out slightly larger, which matches the +45 bytes you flagged yourself.

That isn't a flaw in the measurement, it's the metric: gzip already encodes duplicated function bodies almost for free, so factoring the repetition out buys nothing on the wire while the new helper names are fresh bytes. Dedupe-shaped changes are size-neutral at best, and minified bytes aren't a proxy for what users download.

So this stands or falls as a readability change, and I'd rather keep the explicit def literals in the factory layer — they're easy to scan and easy to diff, and _sf/_ck moves per-factory detail into a shared signature. Thanks for looking into this.

@colinhacks colinhacks closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants