How to Write Tree-Shakeable Library Exports

This guide addresses library design within Tree Shaking and Dead Code Elimination, part of JavaScript Bundle Optimization & Code Splitting. Tree shaking is a contract between library authors and bundlers: bundlers remove code they can prove is unused, and authors write code in shapes that make the proof possible. Packaging matters — ESM output, sideEffects, exports — but no amount of packaging can rescue an API designed around a single object that holds everything.

The classic anti-pattern is the "utility object": export default { format, parse, add, subtract, ... }. Any import of the default export references the object, the object references every function, and everything ships. The tree-shakeable alternative is independent named exports, each a standalone function or component that does nothing until called.

Object API vs named exports Comparison of a library exposing an object of methods with one exposing independent named exports, in terms of what consumers ship. Object API vs named exports export default { format, parse, ... } • One object references every function • Using format ships all 60 helpers • Methods can be reached dynamically • Bundler cannot prove anything unused export function format() ... • Each function stands alone • Using format ships format and its deps • Static imports only • Unused exports removed

Rapid Diagnosis

  • Look for default-exported objects or classes with many static methods. These are the most common non-shakeable shapes.
  • Look for module-scope work. Precomputed tables, registrations, polyfills and environment checks run on import.
  • Look for internal "kitchen sink" imports. A function in format.js importing a large constants.js that also holds unrelated data drags that data along.
  • Test as a consumer. Import one function in a fixture and inspect the bundle.

Root Cause Analysis

1. Aggregating objects. Objects and classes are values; bundlers do not remove individual properties or methods from them.

2. Method chaining APIs. Fluent APIs like moment().add().format() require all methods on the prototype, so all are shipped.

3. Plugin registration at import time. Libraries that auto-register locales, adapters or components on import force inclusion.

4. Shared internal modules that mix concerns. One big internal module imported by everything defeats fine-grained shaking.

Consumer cost of using one date-formatting function Bar chart of bytes a consumer ships to format one date with four library API styles. Consumer cost of using one date-formatting function Chained object API 72KB Default-export utility object 41KB Named exports, shared constants module 12KB Named exports, split internals 3KB

Step-by-Step Resolution

1. Export functions and components by name

typescript
// Before
const utils = { format, parse, addDays, subDays /* ...60 more */ };
export default utils;

// After: each export is independently removable.
export { format } from './format';
export { parse } from './parse';
export { addDays, subDays } from './arithmetic';
// trade-off: named exports change the import style for consumers (no more
// utils.format). Offer a codemod or keep a deprecated default export for one
// major version — but mark it clearly as the non-shakeable path.

Expected outcome: consumers pay only for the functions they import.

2. Prefer functional APIs to chaining

Replace lib(value).add(1, 'day').format('YYYY') with format(addDays(value, 1), 'yyyy'). Functional composition lets each function be shaken independently; chaining forces the whole prototype.

3. Move work out of module scope

typescript
// Before: built on import, whether or not anyone formats anything.
const MONTH_NAMES = buildMonthTable(allLocales);
export function formatMonth(d: Date, locale: string) { return MONTH_NAMES[locale][d.getMonth()]; }

// After: built lazily, once, for the locale actually used.
const cache = new Map<string, string[]>();
export function formatMonth(d: Date, locale: string) {
  if (!cache.has(locale)) cache.set(locale, buildMonthNames(locale));
  return cache.get(locale)![d.getMonth()];
}
// trade-off: the first call per locale pays the build cost. For hot paths,
// let consumers warm the cache explicitly at startup if they want.

Expected outcome: importing the module costs nothing until functions are called, and unused locale data never ships.

4. Make plugins explicit

Instead of auto-registering, export plugins and let consumers pass them in: createFormatter({ locales: [enGB, de] }). Only the plugins referenced are bundled.

Tree-shakeable API checklist Four design rules for library APIs that bundlers can tree-shake effectively. Tree-shakeable API checklist Named exports only No aggregating default objects or classes with static method collections Functional composition over chaining Each operation is its own importable function Nothing happens on import Lazy tables and explicit plugin registration Small internal modules Shared constants split by feature so data follows usage 1 2 3 4

Verification

Write consumer fixtures that import one function each and assert their bundle sizes in CI. Compare fixture size with the function's own source size plus its genuine dependencies — large discrepancies mean something else is being pulled in. Review new exports in code review against the checklist above.

Worked Example: Migrating a Formatting Library

An internal formatting library exposed export default class Fmt with forty static methods and auto-loaded twelve locales at import. Every application that formatted a single currency value shipped 68KB. The team released a new major version with named functional exports, lazy locale tables loaded by explicit import (import { de } from '@acme/fmt/locales/de'), and a codemod for consumers. Applications using two or three functions dropped to 3–6KB from the library; the codemod migrated most call sites automatically, and the deprecated default export remained available for one release with a console warning in development.

Common Mistakes

  • Re-exporting everything from an index that also runs setup code. Keep the index pure.
  • Using enums or large constant objects shared across features. TypeScript enums compile to objects; prefer const unions or per-feature constants.
  • Class-based APIs for stateless helpers. If a class has no instance state, it should be functions.
  • Testing only with your own bundler. Check output with at least webpack and Rollup/Vite; their analyses differ.

Edge Cases in Real Libraries

Framework components with static registration. Web component libraries traditionally call customElements.define() at import time — a side effect by design. Offer two entry points: one that defines elements (for convenience) and one that exports classes for consumers to define selectively.

Global CSS and themes. A component library whose index imports a global theme stylesheet makes every import pull it in. Ship the theme as a separate, explicitly imported file, and keep per-component styles next to their components.

Polyfill-dependent utilities. If a function needs a polyfill, do not import the polyfill at module scope; document the requirement and let the application decide. Otherwise every consumer ships the polyfill, even in browsers that do not need it.

Configuration singletons. Libraries often keep a module-level config object that functions read. That is fine — it is a value, not work — but avoid populating it with defaults computed from heavy imports. Defaults should be cheap literals.

Testing the contract. Shakeability regresses silently when someone adds a convenient module-level call. A consumer fixture that imports one export and asserts the output size, run in the library's CI, catches it on the pull request that introduces it — the only point at which the fix is cheap.

FAQ

Are classes always bad for tree shaking?

No. A class used as a unit — a component, a client — is fine; consumers who import it need all of it. Classes become a problem when they act as containers for unrelated static utilities, or when their definition has side effects (decorators that register, static initialisers that compute).

Do re-export indexes cost anything if done right?

With pure modules and sideEffects declared, modern bundlers follow re-exports and drop unused ones, so the cost is near zero in production. They can still slow development servers in very large libraries; providing per-feature entry points in exports gives consumers a fast path.

How do TypeScript namespaces affect shaking?

namespace declarations compile to objects populated by immediately invoked functions, which bundlers cannot shake. Use ES modules instead of namespaces for any runtime code.

Should a library provide both a namespace import and named exports?

import * as lib from 'lib' works with named exports and is still shakeable when the namespace is only accessed with static property names (lib.format). It becomes non-shakeable when the namespace object is passed around or accessed dynamically. Document named imports as the recommended style and avoid APIs that encourage passing the namespace as a value.

How do I measure whether my library is shakeable?

Build a fixture per major export with each common bundler and record the output size. A shakeable library shows fixture sizes close to each export's own code plus its true dependencies; a non-shakeable one shows similar large sizes for every fixture, because the whole library comes along each time.