Eliminating Barrel File Anti-Patterns
Replace barrel file aggregators with granular exports map entries to restore tree-shaking. Use jscodeshift codemods, ESLint import rules, and CI bundle size gating.
Barrel files (index.ts or index.js re-exporting from sibling modules) look like a convenience, but they silently destroy bundler tree-shaking from Node.js 12 onward and compound the problem across every consumer that installs your package. Without intervention, a single export * from './barrel' forces Webpack 5, Rollup, and esbuild to treat every transitively re-exported module as potentially side-effectful — none of it gets pruned, and the entire namespace lands in every production bundle.
Prerequisites
Before starting the migration, confirm the following:
How barrel files break static analysis
The diagram below shows what happens to a bundler’s module graph when an index.ts barrel sits between the consumer and the actual implementation files.
Dead code elimination depends on static import/export tracing. When a bundler encounters export * from './barrel', it cannot statically guarantee that re-exported modules are free of side effects without evaluating the entire dependency subtree. The bundler’s conservative fallback: mark the barrel and every transitive dependency as retained. The analytics.ts module (highlighted red) ships in the bundle even though consumer.ts never imports anything from it.
The interaction with package.json conditional exports makes this worse: CJS fallbacks inside a barrel often trigger module.exports assignment patterns that break static analysis entirely — the bundler cannot tell what’s exported, so it keeps everything.
Canonical configuration block
The most important single change is replacing the monolithic main/module fields in package.json with granular conditional exports. Every other step — the build config, the codemods, the lint rules — flows from this one change.
{
"name": "@scope/library",
"version": "1.0.0",
// Remove or leave "main" only as a legacy fallback for tools
// that don't read "exports" (very old bundlers / Jest < 28).
"main": "./dist/index.cjs",
"exports": {
// Root entry — kept for `import '@scope/library'`
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
// Wildcard subpath — each component is a direct module,
// no barrel re-export. Node 18+ resolves *.mjs / *.cjs here.
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.mjs",
"require": "./dist/components/*.cjs"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"import": "./dist/utils/*.mjs",
"require": "./dist/utils/*.cjs"
}
},
// Scope sideEffects precisely — never blanket false
"sideEffects": [
"./dist/**/*.css",
"./dist/polyfills/*.cjs",
"./dist/analytics/init.mjs"
]
}
types always appears first in each condition block — TypeScript reads the first matching key, and placing it last risks it being shadowed by a runtime condition that TypeScript also evaluates. See the exports field deep-dive for the full resolution order.
Step-by-step implementation
Step 1 — Align tsconfig.json with your bundler
Misalignment between TypeScript’s moduleResolution and the bundler’s own resolver is the invisible root cause behind silent barrel regressions: TypeScript is happy, the bundler is not.
{
"compilerOptions": {
// "bundler" mirrors Vite/esbuild resolution exactly.
// Use "node16" or "nodenext" for pure Node.js ESM packages.
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022",
"baseUrl": ".",
"paths": {
// Keep aliases pointing at source files, not barrel aggregators.
"@lib/components/*": ["src/components/*"],
"@lib/utils/*": ["src/utils/*"]
},
// Prevents TypeScript from emitting CJS-compat `import =` helpers
// that confuse bundler static analysis.
"verbatimModuleSyntax": true,
// Forces every file to be independently analysable —
// catches patterns that only work through barrel re-exports.
"isolatedModules": true
}
}
Expected output after tsc --noEmit: no errors. Any error TS1479 (The current file is a CommonJS module) means a file still has a stale require() or module.exports that must be converted before the barrel migration proceeds.
HAZARD PREVENTION: Setting
moduleResolution: "node"or"node10"enables legacy implicit.jsextension resolution and aggressive barrel traversal. TS resolves the barrel cleanly during type-checking but the bundler fails at runtime because the compiled output paths don’t match. Always use"bundler","node16", or"nodenext"for new library projects.
Step 2 — Map granular conditional exports
Add the "exports" block from the canonical configuration above to package.json. Then delete (or demote to a legacy comment) the old "module" field — it is a non-standard Bundler Convention field that predates the "exports" spec and is now redundant if "exports" is present.
Verify with publint:
npx publint
A passing run looks like:
✔ "exports['.'].types" resolves to dist/index.d.ts
✔ "exports['.'].import" resolves to dist/index.mjs
✔ "exports['.'].require" resolves to dist/index.cjs
✔ No missing package exports
Step 3 — Configure dual-build tooling with tsup
Replace any barrel-aware bundler config with entry globs so each source file becomes its own output module. The key is entry globbing — it tells tsup to emit one .mjs/.cjs pair per source file, not one merged barrel.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
// Glob all source files; exclude tests and type-only files.
entry: [
'src/**/*.ts',
'!src/**/*.test.ts',
'!src/**/*.spec.ts',
'!src/**/*.d.ts',
],
format: ['esm', 'cjs'],
outDir: 'dist',
// Emit one .d.ts per module (not a barrel .d.ts rollup).
dts: true,
// Disable code splitting — each module stays its own chunk
// so bundlers can tree-shake at the file level.
splitting: false,
clean: true,
sourcemap: true,
treeshake: true,
});
Run the build and inspect the output directory structure:
npx tsup
ls dist/components/
# button.mjs button.cjs button.d.ts
# modal.mjs modal.cjs modal.d.ts
# tooltip.mjs tooltip.cjs tooltip.d.ts
If dist/components/index.mjs is the only file, splitting defaulted to true and tsup merged everything back into a barrel. Explicitly set splitting: false.
HAZARD PREVENTION:
splitting: truein tsup produces a shared-chunk output that mirrors barrel aggregation. Bundlers importing from./components/buttonwill still pull in the entire shared chunk. Always disable splitting for library packages that expose granular subpath exports.
Step 4 — Execute automated codemods with jscodeshift
For large codebases, rewriting every barrel import by hand is error-prone. A jscodeshift transform automates the conversion from import { Button } from '@scope/library' to import { Button } from '@scope/library/components/button'.
# Install jscodeshift as a dev dependency
npm i -D jscodeshift @types/jscodeshift
Create transforms/flatten-barrel-imports.ts:
import type { Transform, ImportDeclaration } from 'jscodeshift';
// Map of named exports to their direct subpath.
// Generate this from your package's exports map, or write it by hand.
const EXPORT_MAP: Record<string, string> = {
Button: '@scope/library/components/button',
Modal: '@scope/library/components/modal',
Tooltip: '@scope/library/components/tooltip',
formatDate:'@scope/library/utils/format-date',
};
const transform: Transform = (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
root.find<ImportDeclaration>(j.ImportDeclaration, {
source: { value: '@scope/library' },
}).forEach((path) => {
const specifiers = path.node.specifiers ?? [];
const newImports = specifiers.map((s) => {
const name = s.type === 'ImportSpecifier'
? s.imported.name
: null;
const target = name && EXPORT_MAP[name];
if (!target) return null;
return j.importDeclaration([s], j.literal(target));
}).filter(Boolean);
// Replace the original import with one direct import per named export
path.replace(...newImports);
});
return root.toSource();
};
export default transform;
Run the transform:
npx jscodeshift \
-t ./transforms/flatten-barrel-imports.ts \
src/ \
--extensions=ts,tsx \
--parser=ts
Expected output:
Results:
42 files changed
0 errors
3 unmodified (no matching imports found)
HAZARD PREVENTION: TypeScript path aliases defined in
tsconfig.jsonunder"paths"are stripped during compilation. An alias like@lib/componentspointing atsrc/components/index.tsquietly re-introduces barrel aggregation at runtime. After the codemod, runtsc-aliasas a post-build step or convert aliases to physical relative paths so the compiled output matches the physical file structure. See path mapping and module resolution strategies for the full alias resolution workflow.
When removing barrel aggregators, precise side-effect declarations become critical. Consult implementing the sideEffects flag correctly to safely declare runtime initialization boundaries without triggering conservative bundler fallbacks.
Step 5 — Enforce with ESLint and gate size in CI
Prevention matters as much as the initial migration. ESLint import rules catch regressions before they reach a reviewer.
{
"plugins": ["import"],
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/**",
"from": "./src/index.ts",
"message": "Import directly from the source module. Barrel files are prohibited."
}
]
}
],
"import/no-duplicates": "error"
}
}
Add size-limit to catch payload regressions before merge:
{
"size-limit": [
{
"path": "dist/index.mjs",
"limit": "15 kB",
"gzip": true
},
{
"path": "dist/components/*.mjs",
"limit": "8 kB",
"gzip": true,
"import": "{ Button, Modal }"
}
]
}
Wire both into GitHub Actions:
name: Bundle Audit
on: [pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run lint # catches barrel import regressions
- run: npm run build # tsup emits per-module output
- run: npx size-limit # fails if any entry exceeds its limit
HAZARD PREVENTION: Relying on final bundle size alone misses intermediate module graph pollution that compounds across monorepo workspaces. Combine ESLint path restrictions with the CI size gate — the lint step catches the architectural regression before a build is even triggered.
Tooling validation
After completing all five steps, run the following commands to confirm the barrel migration is clean:
# 1. Verify package.json exports resolve correctly
npx publint
# 2. Check that TypeScript consumers see the right types
# (install arethetypeswrong globally once)
npx arethetypeswrong --pack .
# 3. Confirm no type errors in the library itself
npx tsc --noEmit
# 4. Run the size gate locally before pushing
npx size-limit
Passing publint output:
✔ All exports resolve correctly
✔ No missing or broken conditions
Passing arethetypeswrong output:
@scope/library 1.0.0
✔ No problems found
If arethetypeswrong reports Resolution failed for any export path, the corresponding types condition in package.json is pointing at a file that was not emitted by tsup. Recheck the dts: true option and the output directory structure.
Compatibility matrix
| Tool / runtime | Wildcard subpath exports | Per-file tree-shaking | sideEffects scoping |
|---|---|---|---|
| Node.js 14 | Partial (static paths only) | N/A | N/A |
| Node.js 18+ | Full | N/A | N/A |
| Webpack 4 | No | Conservative | Respected |
| Webpack 5 | Yes | Full (with sideEffects) |
Respected |
| Rollup 3 | Yes | Full | Respected |
| Rollup 4 | Yes | Full + advanced scope hoisting | Respected |
| Vite 4+ | Yes | Full (via Rollup) | Respected |
| esbuild 0.18+ | Yes | Full | Respected |
| TypeScript 4.x | No "bundler" resolution |
Types only | N/A |
| TypeScript 5.x | Yes ("bundler", "node16") |
Types only | N/A |
“Full” tree-shaking means the bundler can prune at the individual export level within a file. “Conservative” means the entire file is retained if any export is used. Wildcard subpath exports in package.json require Node.js 18+ at runtime and TypeScript 5+ for type resolution.
Pages in this section
- Why Barrel Files Break Tree-Shaking in Webpack — A deep-dive into Webpack 5’s static analysis constraints and exactly why
export *from a barrel defeats module concatenation.
Related
- Implementing the
sideEffectsFlag Correctly — Once barrels are gone, precisesideEffectsscoping is what lets bundlers prune the rest. - Advanced Dead Code Elimination Techniques — Scope hoisting, conditional compilation, and runtime feature flagging for further payload reduction.
- Mastering the
package.jsonExports Field — The full conditional exports spec: condition priority order,defaultfallback, and browser vs Node.js conditions. - Path Mapping and Module Resolution Strategies — How TypeScript
pathsaliases interact with bundler resolution and what breaks when they diverge. - Optimizing Bundle Size for Frontend Libraries — Measurement-first workflow for identifying which modules are responsible for payload growth.