🌳 perf: make the library actually tree-shakable

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 16:20:19 +00:00
parent b4f0657d09
commit 2a2f7345ad
5 changed files with 237 additions and 34 deletions
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
/**
* Tree-shakability is a property of the source that nothing else notices when it breaks.
*
* Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup
* can prove such a call pure by reading the factory, but esbuild and webpack will not. Without
* the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the
* message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather
* than 1818 through webpack. Nothing failed. The library simply got three times heavier in
* every consumer's bundle, and the only way to notice was to go and measure.
*
* So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you
* something drifted, but naming the thing that should not be there says what.
*/
/** Markers that identify a chunk of the library in minified output. */
const MARKER = {
isString: 'must be a string',
minLength: 'must be longer than or equal to',
isLatitude: 'must be a latitude',
isSemVer: 'must be a valid semantic version',
arraySize: 'must contain at least',
serializer: 'Circular reference',
representable: 'cannot be serialized to JSON',
deserializer: 'Unknown property',
validator: '[redacted]',
naming: 'SCREAMING_SNAKE_CASE',
} as const;
const ENTRY = path.resolve('src/index.js').replace(/\.js$/, '.js');
async function bundle(source: string): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-'));
try {
const entry = path.join(dir, 'entry.ts');
await writeFile(entry, source.replace('CEREALE', JSON.stringify(ENTRY)));
const result = await build({
entryPoints: [entry],
bundle: true,
format: 'esm',
minify: true,
target: 'es2022',
write: false,
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
return result.outputFiles[0]!.text;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/**
* Asserts what a bundle kept and what it dropped.
*
* `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an
* external and inlined none of it, contains none of the markers — so an "everything was shaken"
* result and a broken harness look identical without it.
*/
function expectShaken(code: string, keeps: string[], drops: string[]) {
expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200);
for (const marker of keeps) {
expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker);
}
for (const marker of drops) {
expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker);
}
}
describe('tree-shaking', () => {
it('drops the 67 rules you did not import', async () => {
const code = await bundle(`
import { IsString } from CEREALE;
export const d = IsString();
`);
expectShaken(code, [MARKER.isString], [
MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength,
MARKER.serializer, MARKER.deserializer, MARKER.naming,
]);
// Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression
// of the kind above (which trebled it), not to police every byte.
expect(code.length).toBeLessThan(3000);
});
it('keeps the deserializer and drops the serializer when only reading', async () => {
const code = await bundle(`
import { toInstanceSync } from CEREALE;
export const f = (C, p) => toInstanceSync(C, p, { validate: false });
`);
// Validation is kept on purpose: `validate` defaults to true, so the entry point
// references it whatever the call site passes.
expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]);
});
it('keeps the serializer and drops the deserializer when only writing', async () => {
const code = await bundle(`
import { toPlainSync } from CEREALE;
export const f = (o) => toPlainSync(o, { validate: false });
`);
expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]);
});
it('drops both engines when only validating', async () => {
const code = await bundle(`
import { validateSync } from CEREALE;
export const f = (o) => validateSync(o);
`);
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
});
it('costs almost nothing to import only an error helper', async () => {
const code = await bundle(`
import { flattenErrors } from CEREALE;
export const f = (e) => flattenErrors(e);
`);
expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]);
expect(code.length).toBeLessThan(1500);
});
it('still contains everything when everything is used', async () => {
const code = await bundle(`
import * as cereale from CEREALE;
export default cereale;
`);
// The counterweight to every assertion above: proves the markers are findable at all, so a
// "shaken" result upstream means shaken rather than misspelled.
expectShaken(code, Object.values(MARKER), []);
});
});