as runtime dependencies. This prevents the bundler from treating type references as actual module imports, which would otherwise force the bundler to include the entire module graph.
Pair this with ESLint to catch dead expressions that TypeScript ignores:
// eslint.config.mjs
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
export default [
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: { parser: tsParser },
plugins: { "@typescript-eslint": tsPlugin },
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-unused-expressions": "warn"
}
}
];
Architecture Rationale: TypeScript compiles files in isolation. It cannot track cross-file imports. ESLint fills the gap by analyzing expression usage and providing immediate editor feedback. The argsIgnorePattern allows developers to explicitly mark intentionally unused parameters, reducing noise while maintaining strictness.
Layer 2: Project-Graph Analysis
To detect exports that are never consumed anywhere in the codebase, introduce a graph scanner. knip analyzes the entire dependency tree, identifying unused exports, orphaned files, and unimported package.json dependencies. This closes the cross-file blind spot left by the TypeScript compiler.
Install and configure knip:
npm install -D knip
// knip.config.js
export default {
entry: ["src/main.tsx", "src/**/*.test.ts"],
project: ["src/**/*.ts", "src/**/*.tsx"],
ignore: ["**/generated/**", "**/mocks/**", "**/types/**"]
};
Run npx knip to generate a report. The tool flags exports like formatCurrency or LegacyApiClient that exist in the source but have zero import references. In monorepo setups, knip can be configured per workspace using workspaces configuration, ensuring that shared packages do not leak unused APIs into consuming applications.
Layer 3: Bundler Pruning Configuration
Vite delegates production builds to Rollup, which includes tree-shaking by default. Tree-shaking relies on static import/export syntax to trace reachable code from the entry point. Rollup builds a dependency graph, marks all reachable nodes, and discards the rest. However, Rollup defaults to a conservative strategy when it cannot verify side effects or module format.
Declare side effects explicitly in package.json:
{
"sideEffects": false
}
Setting this to false tells Rollup that no module in the project executes code upon import. Rollup can safely drop any file whose exports are not referenced. If your project imports global styles or polyfills, list them explicitly:
{
"sideEffects": ["src/styles/global.css", "src/polyfills.ts"]
}
Architecture Rationale: Rollup's static analysis requires deterministic module resolution. By declaring sideEffects, you give the bundler permission to prune aggressively. Without this field, Rollup assumes every file might execute code on import and retains all modules to prevent runtime breakage.
Ensure shared packages expose ES module entry points. Rollup cannot tree-shake CommonJS because require() resolves dynamically. Publish packages with explicit export conditions:
{
"main": "./dist/index.cjs",
"module": "./dist/index.esm",
"exports": {
".": {
"import": "./dist/index.esm",
"require": "./dist/index.cjs"
}
}
}
The exports field takes precedence over main and module in modern bundlers. Routing the import condition to an ESM build guarantees that consuming applications receive statically analyzable code.
Layer 4: Post-Build Verification
Configuration alone is insufficient. Verify the output using rollup-plugin-visualizer. It generates an interactive treemap of the production bundle, showing exact byte weights per module. This layer confirms that tree-shaking successfully eliminated unused exports and reveals unexpected dependencies.
Add the plugin to vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import visualizer from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
visualizer({
filename: "dist/bundle-report.html",
open: false,
gzipSize: true,
brotliSize: true
})
],
build: {
rollupOptions: {
output: {
manualChunks: undefined
}
}
}
});
Run vite build. The generated report highlights dominant packages and reveals whether tree-shaking successfully eliminated unused exports. Teams can automate size thresholds in CI to fail builds if critical packages exceed acceptable limits.
Pitfall Guide
-
The Per-File Blind Spot
Explanation: Relying solely on TypeScript flags misses cross-file dead exports. The compiler validates each file in isolation and cannot track whether an exported function is imported elsewhere.
Fix: Integrate knip into the CI pipeline to scan the full dependency graph. Configure entry points accurately to avoid false positives.
-
The export * Barrel Trap
Explanation: Wildcard re-exports (export * from "./utils") obscure the dependency graph. Rollup cannot determine which specific exports are consumed, so it retains all referenced modules to preserve potential side effects.
Fix: Replace wildcards with named exports, or import directly from source files. Example: import { formatPrice } from "@scope/utils/format-utils";
-
The sideEffects Omission
Explanation: Without an explicit sideEffects field, Rollup assumes every file might execute code on import. It disables pruning for safety, inflating the bundle with unreachable modules.
Fix: Set "sideEffects": false globally, and whitelist CSS/polyfill files explicitly. Verify that third-party dependencies declare this field correctly.
-
The CJS/ESM Hybrid Mix
Explanation: A single require() call in a file forces Rollup to treat the entire module as CommonJS. Static tree-shaking is bypassed for that file, and all exports are retained.
Fix: Enforce ESM syntax across all source files. Use bundler aliases or build-time transforms if legacy dependencies require CJS. Avoid mixing module formats in the same file.
-
The exports Field Path Mismatch
Explanation: If the import condition points to a non-existent file, Rollup falls back to main (often CJS). Tree-shaking silently fails without warnings, and the consuming app receives an unpruned bundle.
Fix: Verify build output paths match package.json exactly. Add a post-build script to validate export conditions and fail if paths are misaligned.
-
Ignoring CSS Side Effects
Explanation: Setting "sideEffects": false globally strips CSS imports because they have no exports. Styles vanish in production, causing layout shifts and broken UI.
Fix: Use an array whitelist for stylesheets: "sideEffects": ["*.css", "*.scss", "src/styles/**/*"]. Test production builds visually before deployment.
-
Skipping Bundle Verification
Explanation: Assuming tree-shaking works without measuring output leads to false confidence. Configuration drift, dependency updates, and dynamic imports can silently break pruning.
Fix: Automate bundle analysis in CI. Fail builds if critical packages exceed size thresholds. Use rollup-plugin-visualizer or source-map-explorer for regression tracking.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, rapid prototyping | ESLint + TS flags only | Fastest setup, catches local dead code | Low dev overhead, moderate bundle risk |
| Medium app, shared component library | TS + ESLint + knip | Prevents unused exports from leaking into library builds | Moderate CI time, high bundle predictability |
| Enterprise scale, strict performance SLAs | Full pipeline + Visualizer + CI gates | Enforces pruning, catches dependency bloat, guarantees TTI targets | Higher initial config, near-zero runtime waste |
| Legacy codebase with heavy CJS | Gradual ESM migration + @rollup/plugin-commonjs | Allows tree-shaking while maintaining compatibility | Medium migration cost, significant bundle reduction |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"strict": true
}
}
// eslint.config.mjs
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
export default [
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: { parser: tsParser },
plugins: { "@typescript-eslint": tsPlugin },
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-unused-expressions": "warn"
}
}
];
// package.json
{
"sideEffects": ["src/styles/**/*.css", "src/polyfills.ts"],
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
}
}
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import visualizer from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
visualizer({
filename: "dist/bundle-report.html",
open: false,
gzipSize: true,
brotliSize: true
})
],
build: {
rollupOptions: {
output: {
manualChunks: undefined
}
}
}
});
Quick Start Guide
- Initialize static analysis: Add
noUnusedLocals, verbatimModuleSyntax, and ESLint rules to your config. Run tsc --noEmit to surface immediate issues and fix editor warnings.
- Install graph scanner: Run
npm i -D knip, create knip.config.js with your entry and project patterns, and execute npx knip --fix to remove orphaned exports and unused dependencies.
- Configure bundler pruning: Set
"sideEffects": false in package.json, whitelist CSS files, and ensure all internal packages declare exports.import pointing to ESM builds. Verify paths match your build output.
- Verify output: Add
rollup-plugin-visualizer to vite.config.ts, run vite build, and inspect the generated HTML report. Confirm that unused modules are absent from the treemap and chunk sizes align with expectations.
- Enforce in CI: Add
knip and a bundle size threshold check to your pipeline. Fail merges if dead code exceeds acceptable limits or if critical packages regress beyond defined boundaries.