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.
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.jsimporting a largeconstants.jsthat 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.
Step-by-Step Resolution
1. Export functions and components by name
// 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
// 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.
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; preferconstunions 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.
Related
- Using PURE annotations for side-effect-free calls — when exports must be created by calls.
- Building tree-shakeable libraries with Vite library mode — packaging the result.
- Shipping ESM-only packages — the module format these patterns depend on.