A factory function or class whose result is never used should disappear entirely from a production bundle, but bundlers cannot prove that on their own for anything beyond the most trivial function bodies. This guide covers the two comment-based annotations — the call-site PURE marker and the definition-level NO_SIDE_EFFECTS marker — that let library authors give Rollup, esbuild, and webpack the guarantee they need to drop these calls, extending the minifier-level techniques covered in the parent guide.


Root Cause

Static analysis can only prove a function call has no side effects if the entire function body is analyzable and contains no calls to anything the bundler cannot also fully analyze — no console.log, no property writes on shared objects, no calls into unresolved modules. Real factory functions almost never qualify: they call constructors, populate caches, or read configuration, any one of which is enough for a conservative bundler to assume the call might matter and keep it. The purity annotations exist specifically to override that conservative default with an explicit, author-provided guarantee.


Minimal Reproduction

The following library exports a createLogger factory. A consumer imports it but never calls the returned function, yet the factory call itself survives in every unannotated bundler build:

// src/logger.ts
class Logger {
  constructor(private prefix: string) {
    // touches module-level state — enough to make the bundler conservative
    registeredLoggers.push(this);
  }
  log(msg: string) {
    console.log(`[${this.prefix}] ${msg}`);
  }
}

const registeredLoggers: Logger[] = [];

export function createLogger(prefix: string): Logger {
  return new Logger(prefix);
}
// consumer/entry.ts — imports createLogger but never calls it
import { createLogger } from "logging-lib";
console.log("app started");

Because Logger’s constructor pushes into registeredLoggers, the bundler cannot prove createLogger(prefix) is side-effect-free — even though the consumer above never invokes it, an unannotated build retains the entire createLogger/Logger/registeredLoggers chain in case some other, unseen code path depends on the registration.


Two Ways to Annotate Purity

PURE vs NO_SIDE_EFFECTS annotation placement Comparison showing that the PURE annotation is placed at each individual call site and must be repeated everywhere the function is called, while the NO_SIDE_EFFECTS annotation is placed once on the function declaration and applies automatically to every call site across the codebase. PURE (call site) NO_SIDE_EFFECTS (definition) Placed before each call expression Must repeat at every call site Supported by all major bundlers Best for one-off, external calls Placed once above the function Applies to every call automatically Newer, version-dependent support Best for your own factory functions

Step-by-Step Fix

1. Confirm the unused call actually survives the build

Build the minimal reproduction above without any annotation and check for the factory’s name in the output:

npx esbuild consumer/entry.ts --bundle --minify --format=esm --outfile=out/unannotated.mjs
grep -c "registeredLoggers\|Logger" out/unannotated.mjs

A nonzero count confirms the constructor’s side effect on registeredLoggers is forcing retention of the whole chain.

2. Annotate the call site with the PURE marker for a quick, local fix

Wrap the call expression itself. The comment must sit directly before the call with nothing else between it and the parentheses:

// consumer/entry.ts
import { createLogger } from "logging-lib";

// Tells the bundler: even though createLogger's body is not analyzable,
// trust that this specific call has no side effects if unused.
const logger = /*#__PURE__*/ createLogger("app");

This only helps at this one call site — every other place createLogger is called must repeat the annotation.

HAZARD PREVENTION

Symptom: The /*#__PURE__*/ comment is present in source but the call is still retained after minification.

Root cause: A transpiler (Babel, tsc, or SWC) moved or dropped the comment during compilation before the bundler ever saw it, or the annotation was placed before a parenthesized expression rather than directly before the call.

Fix: Inspect the actual file the bundler consumes (post-transpilation) and confirm the comment survives immediately adjacent to the call. For TypeScript, tsc’s default comment handling preserves inline block comments in emitted .js, but bundler-integrated transforms (via ts-loader or swc) can differ — verify with a build, not an assumption.

3. Annotate the function definition with NO_SIDE_EFFECTS so every call site benefits

For a factory function you control and call from many places, annotate the declaration once instead of every call site:

// src/logger.ts
/*#__NO_SIDE_EFFECTS__*/
export function createLogger(prefix: string): Logger {
  return new Logger(prefix);
}

Now any unused call to createLogger anywhere in the consumer’s code is eligible for removal without repeating the annotation:

// consumer/entry.ts — no per-call annotation needed
import { createLogger } from "logging-lib";
const logger = createLogger("app"); // dropped automatically if `logger` is unused

The same syntax applies to arrow functions assigned to const and to exported function expressions:

/*#__NO_SIDE_EFFECTS__*/
export const createLogger = (prefix: string): Logger => new Logger(prefix);

HAZARD PREVENTION

Symptom: A logger that other code depends on for its side effect (registering itself for a diagnostics panel) silently stops appearing in the diagnostics panel after adding NO_SIDE_EFFECTS and enabling minification.

Root cause: NO_SIDE_EFFECTS is an unchecked promise to the bundler, not a verified fact. If any call site’s return value goes unused, the bundler will remove that call — including cases where the author actually needed the constructor’s side effect (the registry push) to run regardless of whether the return value was consumed.

Fix: Only annotate functions whose entire purpose is to compute and return a value with no externally observable effect. If a function is called specifically for its side effect, do not annotate it — instead, keep it in your public API as a distinctly named function (registerLogger) so the bundler’s conservative default protects it.

4. Handle third-party dependencies you cannot annotate directly

When the unused call comes from a dependency’s compiled output, use bundler-level configuration instead of editing node_modules:

// webpack.config.js — treat named functions as pure without touching source (webpack 5.108+)
module.exports = {
  module: {
    parser: {
      javascript: {
        pureFunctions: ["createLogger", "createRegistry"],
      },
    },
  },
};
# esbuild — equivalent CLI flag
npx esbuild consumer/entry.ts --bundle --minify --pure:createLogger --outfile=out/esbuild.mjs

Rollup has no equivalent core configuration option; achieving the same result requires a small plugin that injects /*#__PURE__*/ comments ahead of matched call expressions during the build, run before Rollup’s own tree-shaking pass.


Verification

# Rebuild with the annotation applied and confirm the chain is gone
npx esbuild consumer/entry.ts --bundle --minify --format=esm --outfile=out/annotated.mjs
grep -c "registeredLoggers\|Logger" out/annotated.mjs

Expected: 0 — where the unannotated build reported a nonzero count in Step 1, the annotated build should report none. Repeat the same build and grep for Rollup and webpack to confirm cross-bundler parity, since annotation support varies by version:

npx rollup consumer/entry.ts --file out/rollup.mjs --format es --plugin @rollup/plugin-node-resolve --plugin @rollup/plugin-terser
grep -c "registeredLoggers\|Logger" out/rollup.mjs

Edge Cases / Gotchas

  • Comment placement is exact and unforgiving. /*#__PURE__*/(createLogger("app")) with the annotation outside the parentheses may not match depending on bundler version; place it directly before the identifier or new keyword being called.
  • Class expressions need the annotation before new, not before the class definition. const x = /*#__PURE__*/ new Registry(); is correct; annotating the class Registry {} declaration itself has no effect on PURE semantics (though NO_SIDE_EFFECTS can be placed on an exported factory function that wraps the new call).
  • Minifiers and bundlers must agree on the sigil. Both @__PURE__ and #__PURE__ (and the NO_SIDE_EFFECTS equivalents) are accepted by Terser, Rollup, esbuild, and webpack, but some older tool versions recognized only one sigil — using both consistently across a codebase avoids surprises when a build tool is upgraded or swapped.
  • Cross-file propagation for NO_SIDE_EFFECTS is not universal across older versions. Early esbuild support (around 0.19.10) only tree-shook annotated calls within a single file; confirm the behavior across module boundaries on the exact version pinned in your package.json, not just the latest documentation.
  • TypeScript’s type checker ignores both annotations entirely. Nothing prevents annotating a function that genuinely has side effects — treat these annotations as a runtime-behavior contract enforced only by careful code review and integration tests, not by tsc.
  • /*#__PURE__*/ on a call whose arguments themselves have side effects does not protect those arguments. /*#__PURE__*/ createLogger(sideEffectfulArg()) only asserts the outer call is pure; sideEffectfulArg() is evaluated and analyzed independently and may still be retained or removed based on its own purity.

Frequently Asked Questions

What is the difference between the PURE annotation and the NO_SIDE_EFFECTS annotation?

The PURE annotation marks one call expression at its call site as side-effect-free; you must repeat it at every place the function is called. The NO_SIDE_EFFECTS annotation marks the function’s declaration once, and every call to that function anywhere in the codebase is then treated as side-effect-free automatically. Use PURE for one-off calls to functions you cannot annotate at their definition; use NO_SIDE_EFFECTS for factory or builder functions you control and call repeatedly.

Why did adding a PURE annotation not remove the call from my bundle?

The most common cause is comment placement: the annotation must sit immediately before the call expression or the new keyword with no other syntax between them, including no blank line or extra parentheses. The second most common cause is a build step, such as Babel or TypeScript’s transpiler, stripping or relocating comments before the bundler sees them — verify the annotation survives to the final input the bundler processes, not just your source file.

Can annotating a function as side-effect-free break correctness?

Yes, and this is the primary risk of these annotations. Both PURE and NO_SIDE_EFFECTS are unchecked assertions — the bundler trusts them without verifying the function body. If the annotated function actually performs a side effect on some invocations (a conditional console.log, a registry mutation, a DOM write), the bundler may silently remove a call whose side effect the application depended on, producing a bug that only appears in the minified production build.

Do Rollup, esbuild, and webpack all support both annotations equally?

All three support /*#__PURE__*/, since it originated with UglifyJS and Terser and predates the newer annotation. Support for /*#__NO_SIDE_EFFECTS__*/ arrived later and varies by version: Rollup added it in 4.3, esbuild added initial support around 0.19.10 with an early single-file limitation, and webpack added cross-module propagation in 5.108. Always confirm behavior against the exact bundler version pinned in your project rather than assuming parity — see Comparing Bundler Tree-Shaking Output for how to set up a version-pinned comparison.

Can I mark a third-party dependency’s exports as pure without editing its source?

Yes. webpack’s module.parser.javascript.pureFunctions configuration option (5.108+) lets you list function names to treat as side-effect-free from your own build config. esbuild exposes an equivalent --pure: CLI flag and pure build option. Rollup requires a small plugin, such as one that wraps matched call expressions with a PURE comment during the build, since Rollup does not expose an equivalent core option directly.



↑ Back to Advanced Dead Code Elimination Techniques