Tree Shaking for TypeScript Libraries: Keeping Bundles Genuinely Lean
AI generated
<T>
type
TypeScript · Tree Shaking · Bundle Size · ESM
Tree Shaking for TypeScript Libraries
why unused code still ends up in the bundle

A single import from a poorly structured TypeScript library can pull several hundred kilobytes of unused code into the consumer bundle, despite modern bundlers. Tree shaking is not automatic bundler magic, it depends decisively on how the library itself was built, exported and declared in the sideEffects field.

17 min read sideEffects · ESM · barrel files · Rollup · Webpack TypeScript 5.x · Rollup 4.x

1. Why tree shaking is not an automatic guarantee

Tree shaking describes a bundler's ability to include only the code actually used from an imported module in the final bundle and remove everything else. Many developers assume a modern bundler like Rollup, Webpack or esbuild solves this task automatically and completely for any TypeScript library, as soon as only a single named import is used. In practice, the opposite is often true: numerous popular npm packages pull the entire module content into the bundle for a single import, because certain structural prerequisites are missing.

The reason is that tree shaking relies on static code analysis, which strictly requires certain patterns in the source code. A bundler can only safely decide that a code path is unused and therefore removable if it can prove that executing it has no observable side effects. This exact provability gets lost with certain patterns that are surprisingly common in TypeScript libraries, such as central re-export files or modules that automatically execute code upon import. The result is bundles that stay unnecessarily large despite minimal usage.

2. How bundlers technically detect dead code paths

Modern bundlers use the static structure of ESM imports to build a complete dependency graph before actual execution. Since import/export declarations, unlike require() calls, are fixed and not dynamically alterable at analysis time, the bundler can determine exactly which exports of a module are referenced by which file at all. If an exported name is never imported anywhere, the bundler marks the associated code as a dead code path and removes it in the final build.

This analysis only works reliably, however, if the bundler can additionally prove that the code triggers no side effects merely by being loaded, independent of actual usage. A module that immediately populates a global registry, triggers an HTTP request, or modifies a DOM element upon import must not be removed for correctness reasons, even if none of its exports are imported, because removing it would change the application's observable behavior. This precautionary rule is the main reason tree shaking in real TypeScript libraries is often less effective than pure bundler documentation would suggest.

3. Setting the sideEffects field in package.json correctly

The sideEffects field in package.json solves exactly this dilemma by having the library author explicitly declare which files have side effects when loaded and which are guaranteed to be free of them. "sideEffects": false tells the bundler that every single file in the package may be removed without concern if none of its exports are used. This global statement is the strongest and most effective option for pure utility libraries without initialization code, but it demands care from the author, since an incorrectly set false value can lead to subtle runtime bugs for consumers if a module with side effects actually exists.

For libraries with individual files that have deliberate side effects, such as registering a polyfill or CSS imports, sideEffects additionally allows an array notation with explicit path patterns that are excluded from general removability. This granular declaration is the usual middle ground for larger libraries, where individual modules like a global error handler or a CSS reset must never be removed, while the bulk of the code remains safely tree-shakeable.


{
  "name": "@mironsoft/ui-kit",
  "version": "4.1.0",
  "sideEffects": [
    "*.css",
    "./src/polyfills/intl-fallback.ts",
    "./src/register-global-error-handler.ts"
  ]
}

4. ESM output as a prerequisite for static analysis

For a bundler to perform the previously described static analysis at all, the compiled TypeScript library must be shipped in ESM format, not CommonJS format. require() calls can theoretically be invoked dynamically with computed paths, which makes it impossible for a bundler to safely determine the complete dependency graph statically. Even if a CommonJS file actually contains only static require() calls, bundlers have to proceed conservatively and cannot achieve the same level of certainty as with native ESM syntax.

In tsconfig.json, this concretely means that for tree-shakeable libraries, "module": "ESNext" or "module": "ES2022" must be set for the publicly shipped build, while a separate CommonJS build for legacy consumers can still make sense, but should never exist as the only format. The module field in package.json, not main, is the path bundlers like Webpack and Rollup preferentially evaluate for the ESM build, which is why this field must be maintained for tree-shaking-relevant packages.


// src/index.ts — barrel file with named, statically analyzable exports
export { formatCurrency } from './format-currency';
export { parseDate } from './parse-date';
export { debounce } from './debounce';
export { throttle } from './throttle';

// Consumer only importing formatCurrency:
// import { formatCurrency } from '@mironsoft/ui-kit';
//
// With ESM output + sideEffects: false, a bundler can prove
// that parseDate, debounce and throttle are unreachable and
// safely exclude them from the final bundle.

5. Barrel files: the secret tree-shaking killer

A barrel file, meaning a central index.ts that re-exports all public modules of a library, is convenient for consumers but under certain circumstances a significant obstacle to tree shaking. If even one of the re-exported files contains a single unclear side effect, for example a module-level function whose call the bundler cannot safely prove side-effect free, the bundler's conservative behavior prevents removal of the entire barrel content, even if sideEffects: false is set, unless the bundler trusts that declaration completely.

An additional, practical problem with large barrel files is that IDE autocomplete and static analysis tools become slower with deep re-export chains, since TypeScript has to navigate through multiple indirection layers for every piece of type information. Large libraries like lodash or date-fns have therefore deliberately switched to a model with many small, independent entry points, so consumers can write import debounce from 'lodash/debounce' directly instead of import { debounce } from 'lodash', which sidesteps the tree-shaking problem entirely by never requiring the bundler to analyze the entire barrel content in the first place.


// package.json exports map with individual entry points instead of one barrel
// {
//   "exports": {
//     "./debounce": "./dist/debounce.js",
//     "./throttle": "./dist/throttle.js",
//     "./format-currency": "./dist/format-currency.js"
//   }
// }

// Consumer imports exactly one function, bundler never touches the rest
import debounce from '@mironsoft/ui-kit/debounce';

6. Pure functions and /*#__PURE__*/ annotations

Function calls at module level, such as const instance = createDefaultConfig() directly outside a function, are particularly hard for bundlers to prove side-effect free, even if the called function is actually pure. The bundler would need to analyze the entire function body and rule out that anywhere inside it there is access to global state, a console output, or another side effect, which is practically impossible to prove statically for more complex functions.

The comment annotation /*#__PURE__*/ placed directly before such a function call is a convention supported by Terser, Rollup and esbuild, through which a library author explicitly tells the bundler that this exact call is free of side effects and may safely be removed if its result is unused. TypeScript itself automatically inserts this annotation for certain compiled constructs, for example some class transformations, but for their own module-level initializations, library authors should set the annotation deliberately and sparingly by hand, since an incorrectly placed __PURE__ annotation can remove actually needed code and thereby cause runtime errors for the consumer.


// Module-level call that a bundler cannot prove is side-effect free on its own
const DEFAULT_CONFIG = /*#__PURE__*/ createDefaultConfig();

// Rollup, esbuild and Terser trust this annotation and remove
// the call entirely if DEFAULT_CONFIG is never actually used
// by the consuming application after tree shaking.

export function createDefaultConfig() {
  return { retries: 3, timeoutMs: 5000 };
}

7. Classes, decorators and the limits of tree shaking

Classes present a fundamental problem for tree shaking that cannot be fully solved through conventions alone: a class definition itself is considered potentially side-effect-bearing, because static class properties, static initialization blocks and decorators can execute arbitrary code at definition time. Even if no instance of the class is ever created, the bundler often cannot safely remove the class definition, because it cannot guarantee that these static parts are free of side effects.

For TypeScript libraries where small bundle sizes are a priority, a functional API with pure functions is almost always more tree-shakeable than a class-based API with many static methods. Where classes still make sense for design reasons, for example with stateful objects with clear lifecycle semantics, it helps to avoid static initialization and use decorators only where their actual added value justifies the reduced tree-shaking capability.

8. Measuring bundle size and catching regressions

Without continuous measurement, any claim about a TypeScript library's tree-shaking capability remains pure theory. Tools like size-limit or bundlephobia allow the actual bundle size of a minimal consumer import to be measured automatically and checked in the CI pipeline against a defined ceiling. A typical setup imports only a single function from the library, builds a minimal production bundle from it with the real bundler, and compares its size after minification and gzip compression against the threshold stored in CI.

This automated check surfaces regressions that would otherwise go unnoticed, for example when a new internal dependency is accidentally introduced without a sideEffects declaration, or a refactoring turns a previously pure function into one with a module-level side effect. For publicly distributed TypeScript libraries, such a bundle size check has become part of the standard repertoire of every serious CI pipeline, alongside automated tests or linting.


{
  "size-limit": [
    {
      "name": "formatCurrency (single named import)",
      "path": "dist/esm/index.js",
      "import": "{ formatCurrency }",
      "limit": "2 KB"
    },
    {
      "name": "full library (worst case)",
      "path": "dist/esm/index.js",
      "limit": "18 KB"
    }
  ],
  "scripts": {
    "size": "size-limit"
  }
}

9. Tree-shaking strategies compared

Different structural decisions in library design affect actual tree-shaking effectiveness to varying degrees. The following overview compares the common approaches.

Strategy Tree-shaking effectiveness Consumer convenience Recommendation
CommonJS-only output Very low High Avoid for libraries
ESM output without sideEffects field Moderate, conservative High Better than nothing, but incomplete
ESM + sideEffects: false High High Default for utility libraries
Individual entry points instead of barrel Very high Slightly lower Best choice for large libraries
Class-based API with static init Low Depends on design Only with clear design value

The biggest improvements almost always come from combining ESM output, a correct sideEffects field, and deliberately avoiding large, monolithic barrel files. For very large libraries with many independent functional areas, switching to individual entry points is additionally worthwhile, even though it means slightly more import lines for consumers.

Mironsoft

TypeScript library architecture and bundle size optimization

Ready to noticeably shrink your consumers' bundles?

We analyze your TypeScript library for tree-shaking obstacles, correctly configure sideEffects and ESM output, and set up automated bundle size checks in your CI.

Tree-shaking audit

Analysis of your library for barrel files, side effects and bundle bloat

Refactoring

Optimizing the sideEffects field, ESM output and entry-point structure

CI integration

Automated bundle size checks with size-limit against regressions

10. Summary

Tree shaking for TypeScript libraries is not an automatic property of modern bundlers, but the result of deliberate structural decisions by the library author. ESM output instead of CommonJS is the fundamental technical prerequisite, since only the static import/export syntax allows the bundler to perform a safe analysis. A correctly maintained sideEffects field in package.json determines how aggressively the bundler is allowed to remove unused code without risking observable behavior changes.

Barrel files, module-level function calls without a /*#__PURE__*/ annotation, and class-based APIs with static initialization are the most common practical obstacles that prevent tree shaking in practice, even when the technical prerequisites are met. Continuous bundle size measurement in the CI pipeline makes regressions visible before they lead to unnecessarily bloated applications for consumers.

Tree Shaking for TypeScript Libraries — Key Takeaways

ESM over CommonJS

Only static import/export syntax allows bundlers to safely analyze the dependency graph.

sideEffects field

Global false for pure utility libraries, granular array for individual side-effect files.

Avoid barrel files

Individual entry points instead of a central re-export file for large libraries.

Measure continuously

Check size-limit or bundlephobia in CI against defined ceilings.

11. FAQ: Tree Shaking for TypeScript Libraries

1Is ESM output alone enough?
No, without a correct sideEffects field the bundler stays conservative.
2Risk of an incorrect sideEffects: false?
Subtle runtime errors if side-effect-bearing code actually gets removed.
3Why are barrel files problematic?
A single unclear side effect can prevent removal of the entire barrel content.
4What is __PURE__ used for?
Explicitly signals to bundlers that a specific function call is free of side effects.
5Why are classes less tree-shakeable?
Static properties and decorators can execute code at definition time.
6How do I measure tree-shaking success?
With size-limit or bundlephobia against a defined bundle size ceiling in CI.
7Prioritize main or module?
Bundlers prefer module for ESM, both fields should be kept in sync.
8Why individual entry points for lodash?
Direct imports bypass the barrel problem since the bundler never needs to analyze it.
9Can CommonJS be tree-shakeable?
Only to a limited extent, since require() calls can be dynamic.
10Is a granular array better than false?
Yes, for libraries with actual side-effect files, since it is more precise than a global setting.