| name | tree-shaking | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | Practices for writing tree-shakeable JavaScript/TypeScript library code — a consumer must pay only for what they import. Mandatory rules (pure annotations, side-effect-free modules, honest sideEffects, ESM-only) apply to all library code always; architecture tricks (plugin registries, lazy hooks, settings-as-functions, size-limit contracts) apply on user request. | |||||||||||||
| license | MIT | |||||||||||||
| compatibility |
|
|||||||||||||
| metadata |
|
Goal: a consumer pays only for what they import. Everything below serves that.
This skill has two parts:
- Always — rules to follow in any library code, without being asked. Violating them silently pins dead code into every consumer bundle.
- On request — architecture tricks. Apply them when the user asks to make an API tree-shakeable, reduce bundle size, or design a new library surface.
Put it on every exported (and shared internal) function that is free of observable side effects. It tells the bundler that any call to this function can be dropped when its result is unused — one annotation covers every call site, including consumer code.
/* @__NO_SIDE_EFFECTS__ */
export function signal<T>(value?: T): WritableSignal<T | undefined> {
return createSignal(signalOper, { /* ... */ })
}It also belongs on functions that technically do mutate something, but whose mutation is pointless if the returned value is thrown away — if dropping the call cannot be observed, mark it:
- registering an entry in a registry that can only ever be looked up via the returned key;
- filling a cache nobody will read (
$source.record ??= new Proxy(...)); - attaching lifecycle callbacks to an object nothing else references:
/* @__NO_SIDE_EFFECTS__ */
export function interval(ms: number): WritableSignal<number> {
const $interval = mountable(signal(0))
onStart($interval, () => {
const timer = setInterval(() => $interval($interval() + 1), ms)
return () => clearInterval(timer)
})
return $interval
}Do not mark functions with genuinely observable effects (effect(), DOM writes, subscriptions to external state) — the annotation is a claim, and a false claim makes bundlers silently delete live code.
Required for every function or class call (including new) evaluated at module root. Without it a module-level call is treated as a side effect and pins everything it touches into every bundle that imports the module, however little of it is used.
export const a = /* @__PURE__ */ createElementFactory('a')
export const abbr = /* @__PURE__ */ createElementFactory('abbr')
const reactiveAttributes = /* @__PURE__ */ new Set(['href', 'src'])
const flatHandler = /* @__PURE__ */ createProxyHandler(child => child)Applies to third-party calls too (/* @__PURE__ */ createContext(...)). If the callee is already marked @__NO_SIDE_EFFECTS__, the call-site annotation is technically redundant — the two are complementary and either one suffices — but annotating the call site anyway is the safe default.
Beyond annotated calls, a module top level must contain only declarations. Concretely:
- No imports-for-effect (
import './setup') in shakeable code. - Module-level mutable state starts as a bare
let x: T | undefined(free to declare) and is lazily initialized (x ??= new Set()) inside the function that needs it — notconst x = new Set()eagerly. - Barrel files (
index.ts) are pure re-exports only: no constants, no initialization. Useexport type * from './types.js'for type-only modules so they are erased at compile time instead of left for the bundler to reason about.
Every publishable package sets "sideEffects": false — if it is true. It is a claim, not a decoration:
- If an entry genuinely installs something at import time (e.g. a framework adapter that patches a hook on import), declare
"sideEffects": true(or list the specific files in an array). Lying lets bundlers drop the whole module. - CSS or polyfill files that must survive go into the array form:
"sideEffects": ["./dist/index.css"].
"type": "module",exportsmap only; no CJS build, no legacymain/modulefields. Bundlers can only tree-shake statically analyzable ESM.- Build with a modern target (
esnext) so no transpile helpers orObject.definePropertyshims appear — they defeat static analysis. Beware transpiler class transforms that break@__PURE__. - Publish unminified dist (
minify: false) so the annotations survive intonode_modules; minification is the consumer's job. - In a monorepo, externalize workspace dependencies in each package's build config instead of bundling them in — one shakeable module graph, no duplicated copies.
enum emits a runtime IIFE object — unshakeable and non-inlinable; const enum breaks isolatedModules. Use plain const numbers/strings (bit flags as 1 << n), which minify to inlined literals:
export const NoneFlag = 0
export const MutableFlag = 1 << 0
export const MountableMode = 1 << 3- Export individual named functions, not a default-exported monolith.
- Never make prototype methods the API surface: methods can't be tree-shaken, so a class with
.map(),.filter(),.subscribe()ships all of them to every consumer. Expose standalone functions operating on the value instead. Classes are acceptable only where the whole unit is always used together and is off the size-critical path (e.g. an SSR renderer); for variants, prefer injecting a behavior function orObject.create(base)forking over subclassing.
Tricks to apply when the user asks to design a tree-shakeable API, shrink a bundle, or restructure a library.
A switch over features references every implementation and is unshakeable. Instead, make every feature an independently importable const whose value is just its id, registered as a side effect of creating that const — implementation reachable only through the import:
export const effectAttributes = new Map<string, EffectAttributeCallback>()
/* @__NO_SIDE_EFFECTS__ */ // registering an entry nobody can name is unobservable
export function createEffectAttribute(id: string, callback: EffectAttributeCallback) {
effectAttributes.set(id, callback)
return id
}
// feature module — importing `classList$` is what pulls the implementation in
export const classList$ = /* @__PURE__ */ createEffectAttribute('classList$', (element, value) => { /* ... */ })Attach the feature's types via declare module '...' augmentation — zero runtime cost. Chart.js's Chart.register(...) is the same idea: consumers register only what they use.
When a core needs an optional subsystem (lifecycle, dev extensions), don't call install() unconditionally. Keep undefined-initialized module-level hook slots and assign them only from a code path reachable exclusively via the optional public API:
let lifecycleEdge: ((dep: Node, sub?: Node) => void) | undefined
let lifecycleSettle: (() => void) | undefined
// reachable only via mountable()/onMounted() — apps that never mount drop the whole subsystem
export function touchLifecycle(node: Node): void {
lifecycleEdge = onEdge
lifecycleSettle = settle
settle()
}Hot paths pay only lifecycleSettle?.(). If nothing imports the optional API, onEdge/settle and everything they reference disappear.
Instead of a central runner that switches over descriptor kinds (referencing every handler), return plain objects holding a reference to their own handler — importing title() pulls in only startProperty:
/* @__NO_SIDE_EFFECTS__ */
export function title($value: TitleValue): TitlePropertyDescriptor {
return { tag: 'title', value: $value, start: startProperty }
}An options object { dedupeTime, cacheTime, mapError } forces one parser that touches every option. Individual closure factories let unused options vanish:
/* @__NO_SIDE_EFFECTS__ */
export function dedupeTime(time: number): ClientSetting {
return ctx => ctx.dedupeTime = time
}fn.bind(null, arg) produces a specialized function without a new function body, and combined with @__NO_SIDE_EFFECTS__ on the factory it is a fully droppable expression:
/* @__NO_SIDE_EFFECTS__ */
export function createElementFactory<Tag extends ElementName>(tag: Tag) {
return createElement.bind(null, tag) as ElementFactory<Tag>
}When a package has environment-specific or clearly separable parts, give them their own exports entries with no shared root so, e.g., server-only code can never be reached from a client import path:
"exports": {
".": "./dist/index.js",
"./vite-plugin": "./dist/vite-plugin.js",
"./renderer": "./dist/renderer.js",
"./client": "./dist/client.js",
"./package.json": "./package.json"
}Annotations rot unless tested. Set up size-limit checks by following the size-limit skill. The tree-shaking-specific point: measure several import subsets of the same dist file (e.g. { signal }, { signal, computed, effect }, *), so a shakeability regression shows up as a specific entry blowing its limit rather than a vague total — the delta between subsets is the size of the subsystem being measured as droppable.
For one-off checks of a tricky expression, use locally available tools (rollup/rolldown/terser). Write a tiny entry that imports the module but leaves the result unused, bundle it, and check what survives:
echo "import { thing } from './dist/index.js'" > /tmp/entry.js
npx rollup /tmp/entry.js --silent --format es # empty output = fully dropped
# or: npx rolldown /tmp/entry.js --silent
# or pipe a single expression through the minifier:
echo "const x = /* @__PURE__ */ create();" | npx terser --module --compress --toplevel