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.

Barrel file import graph vs. direct import graph Left side shows a consumer importing from an index.ts barrel which re-exports four modules (button, modal, tooltip, analytics), all four are retained in the bundle. Right side shows a consumer importing only button.ts directly; only button is retained. WITH BARREL FILE DIRECT IMPORTS consumer.ts index.ts (barrel) button.ts modal.ts tooltip.ts analytics.ts All 4 modules in bundle consumer.ts button.ts modal.ts tooltip.ts analytics.ts Only button.ts in bundle

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 .js extension 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: true in tsup produces a shared-chunk output that mirrors barrel aggregation. Bundlers importing from ./components/button will 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.json under "paths" are stripped during compilation. An alias like @lib/components pointing at src/components/index.ts quietly re-introduces barrel aggregation at runtime. After the codemod, run tsc-alias as 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.


The Cost Model: Why One Barrel Import Pulls in Everything

A barrel file feels free because it changes nothing at runtime for a consumer who imports one symbol — the extra modules are evaluated, but the result is the same. The cost is paid in the module graph, and it compounds in a specific pattern worth seeing laid out.

Barrel import versus direct subpath import On the left, a consumer imports through index.ts and the graph reaches button, modal, tooltip and their transitive dependencies. On the right, the consumer imports the button subpath directly and the graph contains only button and its own dependency. Same symbol, two very different module graphs through the barrel import { Button } from "ui" index.ts re-exports all button modal tooltip every sibling is parsed, and kept if any is impure direct subpath from "ui/button" button.ts only its own dependency nothing else is ever loaded

The graph on the left is not automatically larger in output — a bundler with a clean side-effect story eliminates modal and tooltip fine. What it always costs is work: every sibling is resolved, read, parsed, and analysed, on every build, for every consumer. In a design system with two hundred components, that is the difference between a development server that reloads in 300 ms and one that takes four seconds, long before it is a difference in shipped bytes.

And the elimination is fragile in a way the direct import is not. One sibling with a module-level side effect — a CSS import, a global registration, a polyfill — anchors itself, and depending on the bundler’s granularity it can anchor more. The direct-subpath consumer is structurally immune: the module was never in the graph, so nothing about its purity matters.

Migrating Away Without Breaking Consumers

Removing a barrel from a published package is a public API change, so it needs a migration path rather than a deletion. The sequence that works has four steps and can be spread over two releases.

Step one: add subpath exports alongside the barrel. Every module that consumers should be able to reach gets its own entry, while the root export keeps working exactly as before. This release is a minor — nothing breaks, new paths appear.

{
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" },
    "./button": { "types": "./dist/button.d.ts", "import": "./dist/button.mjs", "default": "./dist/button.mjs" },
    "./modal": { "types": "./dist/modal.d.ts", "import": "./dist/modal.mjs", "default": "./dist/modal.mjs" }
  }
}

Step two: make the barrel side-effect-free and prove it. Before asking anyone to migrate, ensure the root import is as cheap as it can be — "sideEffects": false (or a precise array), no module-level work in index.ts, and explicit export { Button } from "./button.js" rather than export *. Explicit re-exports give every bundler an exact list to trace, which is what makes the barrel eliminable at all.

Step three: document the subpaths as the recommended form and, if you generate documentation, change the examples. Most consumers copy from examples; changing the examples migrates the majority without any announcement.

Step four: decide whether the barrel goes away. For many packages it should not — a barrel is genuinely convenient, and once it is side-effect-free and explicitly re-exported, its cost for a modern bundler is build time rather than bytes. Removing it is a major release and should be justified by a measurement, not by principle.

An automated codemod makes step three real for consumers who ask for one. Publishing a small jscodeshift transform in the repository, and linking it from the changelog, turns “please update your imports” into a command:

npx jscodeshift -t ./codemods/barrel-to-subpath.ts src/ --parser=tsx
Processing 214 files...
 214 files, 61 modified
Rewrote 173 imports from "@scope/ui" to component subpaths

HAZARD PREVENTION

Symptom: After splitting the barrel into subpaths, consumers report that types resolve for @scope/ui but not for @scope/ui/button, with Cannot find module '@scope/ui/button' or its corresponding type declarations.

Root cause: The subpath entries carry runtime conditions but no types condition, or the consumer is on moduleResolution: node, which ignores exports entirely and looks for a physical button/index.d.ts folder.

Fix: Give every subpath its own types condition first in the object, and — if you still support legacy resolution — either ship a physical folder per subpath or add a typesVersions map that points the old resolver at the right declaration file.


The Migration Across Two Releases

Spreading the change over two releases keeps every consumer working while the recommended shape moves.

Barrel migration timeline A first minor release adds subpath exports and makes the barrel side-effect-free while keeping it working. Documentation then moves to subpaths. Only an optional later major release removes the barrel, and only if a measurement justifies it. Nothing breaks until the optional last step minor release add subpath exports make the barrel pure explicit re-exports only consumers unaffected documentation examples use subpaths codemod published changelog explains why migration is voluntary optional major remove the barrel only with a measurement to justify the break often better left in place

Most packages stop after the second column, and that is a legitimate end state: a pure, explicitly re-exporting barrel alongside real subpaths gives consumers the choice without costing them anything.


Pages in this section



Back to Tree-Shaking & Bundle Optimization