From 2a2f7345ad671a461aa979817d17a3ce1a0fb820 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:20:19 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=B3=20perf:=20make=20the=20library=20a?= =?UTF-8?q?ctually=20tree-shakable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing one decorator pulled in the message and validator of all 68 — 4,909 bytes instead of 1,837 through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing warned. The library was simply about three times heavier than it needed to be in every consumer's bundle. Every rule is a top-level call: `export const IsString = rule(...)`. rollup proves such a call side-effect-free by reading the factory, which is why it was already emitting 1,823 bytes — and why a single-bundler measurement would have shown no problem at all. esbuild and webpack do not do that analysis and keep the call. Thirty declarations now carry /*#__PURE__*/, which tsc preserves into the ESM emit, and all three bundlers now land within 20 bytes of each other. Measured, minified, esbuild / rollup / webpack: flattenErrors 287 / 292 / 291 one decorator 1837 / 1823 / 1818 validateSync 3722 / 3554 / 3823 toPlainSync 7744 / 7769 / 7832 toInstanceSync 7900 / 7942 / 7956 a typical DTO 10395 / 10402 / 10372 everything 26266 / 25671 / 26879 The serializer and deserializer drop independently — read JSON and you do not pay for writing it. Both mapping entry points keep the validator, because `validate` defaults to true and that is a real reference rather than a missed optimisation. The annotations are a promise to the bundler, so I checked the three factories they cover: rule, pattern and affix each return a closure and touch nothing outside themselves. A false promise here would mean silent deletion in someone else's production build. src/treeshake.test.ts pins the property. It asserts content rather than only bytes — it names the rules that must not appear — and one case asserts everything IS present when everything is used, so a "shaken" result cannot come from a bundle that failed to build. That mattered: two earlier passes at this measurement reported a clean sweep of shaken symbols because rollup had failed to resolve its entry and grep was reading missing files as absence. Strip the annotations and the test fails with `"must be a latitude" should have been shaken out`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- CHANGELOG.md | 34 +++++++++++ FRAMEWORKS.md | 10 +-- README.md | 28 +++++++++ src/decorators.ts | 60 +++++++++--------- src/treeshake.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 34 deletions(-) create mode 100644 src/treeshake.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5460af..3d5341b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,40 @@ rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake Since the size argument is a wash, the per-module build stays the default `import` for the one thing it does better — readable stack traces for anyone not loading source maps. +### Tree-shaking + +Importing one decorator pulled in the message and validator of all 68. **4,909 bytes instead of +1,837** through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing +warned; the library was simply about three times heavier than it needed to be in every +consumer's bundle. + +The cause is that every rule is a top-level call — `export const IsString = rule(…)`. rollup +proves such a call side-effect-free by reading the factory, which is why rollup was already +producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and +webpack will not do that analysis, and keep the call. Thirty declarations now carry +`/*#__PURE__*/`, and all three bundlers land within 20 bytes of each other. + +Measured, minified, across esbuild / rollup / webpack: + +| What you import | esbuild | rollup | webpack | +| --- | ---: | ---: | ---: | +| `flattenErrors` | 287 | 292 | 291 | +| one decorator | 1,837 | 1,823 | 1,818 | +| `validateSync` | 3,722 | 3,554 | 3,823 | +| `toPlainSync` | 7,744 | 7,769 | 7,832 | +| `toInstanceSync` | 7,900 | 7,942 | 7,956 | +| a typical DTO | 10,395 | 10,402 | 10,372 | +| everything | 26,266 | 25,671 | 26,879 | + +The serializer and deserializer drop independently. The validator is kept by both mapping +entry points because `validate` defaults to `true`, which is a real reference rather than a +missed optimisation. + +`src/treeshake.test.ts` pins it. The assertions are mostly about content rather than bytes — it +names the rules that must not appear — and one case asserts that everything IS present when +everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip +the annotations and it fails with `"must be a latitude" should have been shaken out`. + ### Frameworks [FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written, diff --git a/FRAMEWORKS.md b/FRAMEWORKS.md index 1d11c6a..aa34299 100644 --- a/FRAMEWORKS.md +++ b/FRAMEWORKS.md @@ -329,10 +329,12 @@ await esbuild.build({ ## A single file, no bundler `cereale/min` is the whole library flattened into one minified ES module (25.5 KB, 8.6 KB -gzipped) for import maps, `