Why Getters and Class Statics Block Elimination
Module-scope getters, static initialiser blocks and decorators anchor their whole module. See why analysers must keep them, and the deferral patterns that free them.
A consumer imports one small function and their bundle contains a module they never touched. The code looks perfectly static — no require, no dynamic keys, no side effects a reader would notice:
export class Registry {
static instances = new Map<string, Registry>();
static { Registry.instances.set("default", new Registry()); } // runs at definition
}
export function formatBytes(n: number): string { return `${(n / 1024).toFixed(1)} kB`; }
Importing formatBytes retains Registry as well, because the static block runs when the class is defined and the analyser cannot prove that running it is unobservable.
Root Cause
Dead-code elimination keeps whatever it cannot prove is unused. Three constructs make that proof impossible, and they share one property: they cause code to run at module evaluation time rather than when something is called.
A getter definition is a call to Object.defineProperty, and accessing the property later runs arbitrary code — neither is provably pure. A static initialiser block or a static field with a computed initialiser executes when the class is defined, which makes the definition an observable operation. A decorator is a function invoked at definition time, usually registering something.
None of these are bugs in any bundler. They are all cases where dropping the code would change what the program does, and a correct analyser must keep them.
Minimal Reproduction
// src/registry.ts
const CONVERSIONS = new Map<string, number>([/* 400 entries */]);
export class Units {
static table = buildTable(CONVERSIONS); // computed static field
static { Units.table.set("default", 1); } // static block
}
export const format = (n: number): string => `${n}`;
printf 'import { format } from "@scope/my-library";\nconsole.log(format(1));\n' > probe.ts
npx esbuild probe.ts --bundle --format=esm --minify --outfile=out.mjs
grep -c "CONVERSIONS\|buildTable" out.mjs
3
The conversion table and its builder are in a bundle whose only import was a one-line formatting function.
Which Constructs Anchor a Module
Step-by-Step Fix
1. Move definition-time work into a function
export class Units {
- static table = buildTable(CONVERSIONS);
- static { Units.table.set("default", 1); }
+ static #table: Map<string, number> | undefined;
+
+ static get table(): Map<string, number> {
+ if (!Units.#table) {
+ Units.#table = buildTable(CONVERSIONS);
+ Units.#table.set("default", 1);
+ }
+ return Units.#table;
+ }
}
Note what changed and what did not: there is still a getter, but it is now a class member accessed on demand rather than module-scope work performed at evaluation. The table is built on first access, so a consumer who never touches Units never pays for it.
npx esbuild probe.ts --bundle --format=esm --minify --outfile=out.mjs
grep -c "CONVERSIONS" out.mjs || echo "0 — eliminated"
Expected output: 0 — eliminated.
2. Split the module when deferral is not possible
Where the definition-time work is genuinely required — a plugin that must register itself on import — the answer is to isolate it so it anchors only itself:
src/
- registry.ts ← formatting helpers AND the registering class
+ units.ts ← the class with its registration, imported deliberately
+ format.ts ← pure helpers, freely eliminable
{
"exports": {
"./units": { "import": "./dist/units.mjs", "default": "./dist/units.mjs" },
"./format": { "import": "./dist/format.mjs", "default": "./dist/format.mjs" }
}
}
3. Replace module-scope getters with plain functions
- export const config = {};
- Object.defineProperty(config, "endpoint", {
- get: () => process.env.API_URL ?? "https://api.example.com",
- });
+ export const getEndpoint = (): string => process.env.API_URL ?? "https://api.example.com";
The getter form was never buying much: it reads slightly more nicely at the call site and costs the entire module’s eliminability.
4. Confirm the anchor is gone with a metafile
npx esbuild probe.ts --bundle --format=esm --minify --metafile=meta.json --outfile=out.mjs
node -e '
const m = require("./meta.json");
const out = Object.values(m.outputs)[0];
Object.entries(out.inputs)
.filter(([, v]) => v.bytesInOutput > 0)
.sort((a, b) => b[1].bytesInOutput - a[1].bytesInOutput)
.slice(0, 5)
.forEach(([f, v]) => console.log(String(v.bytesInOutput).padStart(6), f));
'
HAZARD PREVENTION
Symptom: Deferring the work fixes elimination and changes behaviour — a value that used to exist immediately after import is now
undefineduntil first access.Root cause: The definition-time work was load-bearing for a consumer that read the value directly rather than through the accessor.
Fix: Keep the accessor as the only public path to the value, and treat direct access to the underlying field as an internal detail. Where consumers legitimately need eager initialisation, export an explicit
init()they call — which is a documented API rather than an accident of module evaluation order.
Verification
# a single-symbol import stays small
printf 'import { format } from "@scope/my-library";\nconsole.log(format(1));\n' > probe.ts
npx esbuild probe.ts --bundle --format=esm --minify --outfile=one.mjs
echo "single-symbol: $(gzip -c one.mjs | wc -c) bytes gzipped"
# and does not contain the deferred data
grep -q "CONVERSIONS" one.mjs && echo "FAIL: table retained" || echo "ok: table eliminated"
Expected: a small byte count and ok: table eliminated.
Edge Cases / Gotchas
- A lazy getter is not free of ordering effects. Building on first access moves the work rather than removing it, which can shift a cost into a latency-sensitive path; measure where it lands.
- Private static fields are still definition-time if computed. The
#prefix changes visibility, not when the initialiser runs. - Bundlers differ on how far a bailout propagates. One may retain only the anchored class, another the whole module; the fix is the same either way.
- Decorator metadata cannot be deferred. If the pattern requires registration at definition time, isolating the decorated classes in their own module is the only lever available.
- TypeScript’s
useDefineForClassFieldschanges emitted semantics. Flipping it can turn a field that was previously assigned in a constructor into a definition-time property, introducing an anchor without any source change.
Frequently Asked Questions
Why is a getter treated as a side effect?
Defining one is a call to Object.defineProperty, and reading it runs arbitrary code. Neither can be proven pure without executing it, so both are kept.
Do static class fields prevent elimination too?
A plain literal field is usually fine. A computed initialiser or a static block runs at class-definition time, which anchors the module.
Does a pure annotation help here?
Only for call expressions whose results are unused. It cannot make a getter definition or a static block droppable — those need a structural fix.
Are decorators a problem for the same reason?
Yes. A decorator is invoked at definition time and usually registers metadata, so both it and its target are retained.
How much size does this actually cost?
Entirely dependent on what the anchored module drags in — a static block referencing a large table can retain tens of kilobytes for an unrelated import.
Related
- Advanced Dead Code Elimination Techniques — the retention chain this is one link of.
- Pure Annotations for Dead Code Elimination — what annotations can and cannot express.
- Implementing the sideEffects Flag Correctly — the module-level declaration these constructs quietly contradict.