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

Constructs that run at definition time Getters, computed static fields, static initialiser blocks and decorators all execute when the module is evaluated, which makes them observable and prevents an analyser from removing them or the module that contains them. All four run before anything calls anything module-scope getter defineProperty is a call; reading runs arbitrary code computed static field the initialiser runs when the class is defined static initialiser block arbitrary statements, executed at definition decorator a function call that usually registers metadata

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

Tracing a retained module The metafile shows which inputs contributed bytes to the output. Following the retained module's importers leads to the construct that anchored it, which is where the fix belongs. Retention has a cause, and the metafile names it bytes in output registry.ts: 8.2 kB who imports it index.ts, via a barrel why it is kept a static block at line 12 Fix the third box; the first two resolve themselves
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 undefined until 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 useDefineForClassFields changes 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.



↑ Back to Advanced Dead Code Elimination Techniques