Replacing Barrel Exports with Direct Module Imports

Symptom you will see: production bundles grow by 12–18% even after adding sideEffects: false to package.json. A bundle visualizer shows modules you never imported — entire sibling files from a component library or utility package — retained inside your main chunk. Console output from Webpack 5 stats shows unused harmony export annotations on dozens of symbols, yet those modules are still included in the emitted asset.

The root cause is barrel aggregation: an index.ts that re-exports every module in a directory with export * from './Button' or export * from './utils'. This page gives you the exact diagnosis steps, the codemod that fixes it, the bundler configuration that enforces it, and the verification checklist that proves it worked.

Barrel import vs direct import — effect on the module graph Left side shows a barrel index.ts pulling in Button, Modal, Tooltip, and Icon modules when only Button is needed. Right side shows a direct import of Button with the other modules absent from the graph. BARREL IMPORT (before) App.ts index.ts (barrel) Button Modal Tooltip Icon Modal, Tooltip, Icon retained even when unused (+14% chunk) DIRECT IMPORT (after) App.ts Button.ts Only Button in the graph. Modal / Tooltip / Icon absent.

Root Cause Analysis

Barrel files (index.ts / index.js) that use wildcard re-exports — export * from './Modal' — force Webpack 5 and Vite 5+'s Rollup core into conservative evaluation mode. During AST traversal the bundler encounters the re-export and cannot build a static symbol list without first evaluating every module the barrel re-exports. Because the full symbol set is unknown until runtime, the bundler defaults to retaining all re-exported modules to prevent a missing-export error.

This is a deeper problem than side-effect marking alone. Even when sideEffects: false is set in package.json, the bundler still needs to traverse the barrel’s re-export chain to determine which files it is safe to drop. In mixed CommonJS / ESM environments (common in design-system packages), that traversal triggers CJS evaluation, which the bundler cannot statically prune at all.

This page is the surgical fix for that failure mode. The broader strategy for refactoring barrel files to reduce bundle bloat covers audit workflows and file-level inventory before you reach the codemod stage covered here. That cluster sits within the advanced tree-shaking and dependency optimization topic, where you will find complementary techniques around sideEffects configuration and CJS-to-ESM conversion.

Exact Config and CLI Fixes

Total estimated time: diagnostic (5 min) → codemod (10 min) → config patch (5 min) → build validation (5 min).

Step 1 — Isolate barrel aggregation in the bundle graph

Run the visualizer for your bundler:

# Webpack 5 — generates stats.json then opens the interactive visualizer
npx webpack --profile --json > stats.json && npx webpack-bundle-analyzer stats.json

# Vite 5+ — build with the rollup-plugin-visualizer already in vite.config.js
npx vite build --mode production

In the visualizer, look for large rectangles from modules you did not directly import. If components/Modal.ts appears inside your main chunk but your application only calls Button, you have confirmed barrel retention.

Verify at the stats level:

# Webpack 5 — inspect retained modules
node -e "
const s = require('./stats.json');
s.modules
  .filter(m => m.reasons.some(r => r.type === 'harmony side effect evaluation'))
  .forEach(m => console.log(m.name, m.size));
"

Any module whose only reason is harmony side effect evaluation from a barrel index is a candidate for elimination.

Step 2 — Apply the jscodeshift codemod

Create the transform at scripts/direct-import-transform.js:

// Webpack 5 / Vite 5+ compatible — jscodeshift transform
// Rewrites:  import { Button } from '@lib'
// To:        import { Button } from '@lib/Button'
// Requires:  package.json exports map that exposes individual subpaths (Step 3)

const BARREL_SPECIFIER = '@lib'; // replace with your barrel package/alias
const SUBPATH_MAP = {
  Button:  '@lib/Button',
  Modal:   '@lib/Modal',
  Tooltip: '@lib/Tooltip',
  Icon:    '@lib/Icon',
  // extend for every exported symbol
};

module.exports = function transformer(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root.find(j.ImportDeclaration, { source: { value: BARREL_SPECIFIER } })
    .forEach(path => {
      const specifiers = path.node.specifiers;
      const newDeclarations = specifiers.map(spec => {
        const localName = spec.local.name;
        const importedName = spec.imported.name;
        const targetPath = SUBPATH_MAP[importedName] || `${BARREL_SPECIFIER}/${importedName}`;
        return j.importDeclaration(
          [j.importSpecifier(j.identifier(importedName), j.identifier(localName))],
          j.literal(targetPath)
        );
      });
      j(path).replaceWith(newDeclarations);
    });

  return root.toSource();
};

Run it across the entire source tree:

# Webpack 5 / Vite 5+ — dry-run first, then apply
npx jscodeshift --dry -t ./scripts/direct-import-transform.js src/ --parser=ts
npx jscodeshift -t ./scripts/direct-import-transform.js src/ --parser=ts

Circular barrel references: if a barrel module re-exports symbols from another barrel in the same package, isolate shared types into a dedicated @lib/types subpath before running the transform. Attempting to rewrite circular re-exports without first breaking the cycle will produce import cycles that Webpack 5 handles but which block Rollup/Vite 5+ module graph traversal.

Namespace collisions: when two subpaths export a symbol with the same name, the transform will produce duplicate import declarations. Resolve them by adding explicit as aliases in the generated imports, then registering the aliased names in tsconfig.json paths.

Step 3 — Enforce subpath exports in package.json

Prevent future code from accidentally importing through the barrel by exposing only explicit subpaths:

// package.json — Webpack 5 and Vite 5+ both respect the Node.js exports map
{
  "name": "@lib",
  "exports": {
    ".": "./src/index.ts",
    "./Button":  "./src/Button.ts",
    "./Modal":   "./src/Modal.ts",
    "./Tooltip": "./src/Tooltip.ts",
    "./Icon":    "./src/Icon.ts",
    "./utils/*": "./src/utils/*.ts"
  }
}

Mirror these paths in tsconfig.json so TypeScript resolves them correctly:

// tsconfig.json — must mirror package.json exports map
{
  "compilerOptions": {
    "paths": {
      "@lib":          ["./node_modules/@lib/src/index.ts"],
      "@lib/*":        ["./node_modules/@lib/src/*.ts"]
    }
  }
}

Step 4 — Patch the bundler configuration

Webpack 5:

// webpack.config.js — Webpack 5
module.exports = {
  // usedExports enables per-export dead-code marking
  // concatenateModules merges small modules to reduce overhead
  optimization: {
    usedExports: true,
    concatenateModules: true,
    innerGraph: true, // tracks symbol-level dependencies across module boundaries
  },
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        sideEffects: false, // trust package.json sideEffects declarations
      }
    ]
  }
};

Vite 5+ (Rollup core):

// vite.config.js — Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // exclude internal barrel libs from pre-bundling
    // so Rollup sees their raw ESM rather than a CJS wrapper
    exclude: ['@lib'],
  },
  build: {
    rollupOptions: {
      treeshake: {
        // false = Rollup decides per-module based on exports map
        moduleSideEffects: false,
        // prevents Rollup retaining modules only because a property was read
        propertyReadSideEffects: false,
        // inlines calls to pure functions declared with @__PURE__ annotation
        annotations: true,
      }
    }
  }
});

moduleSideEffects: false tells Rollup to treat every module as having no side effects unless a module explicitly opts in via the package.json sideEffects field. Audit every third-party dependency for global-state registration before enabling this globally — any module that patches Array.prototype or registers a service worker on load must be whitelisted explicitly.

Step-by-Step Verification

  1. Rebuild and inspect stats output

    # Webpack 5
    npm run build -- --stats-json
    node -e "
      const s = require('./dist/stats.json');
      const retained = s.modules.filter(m =>
        m.reasons.length > 0 &&
        m.reasons.every(r => r.type === 'harmony side effect evaluation')
      );
      console.log('Retained via side-effect evaluation:', retained.length);
    "

    Target: 0 modules retained solely via side-effect evaluation.

  2. Measure gzip delta against the pre-migration baseline

    du -sh dist/assets/*.js
    # compare against the baseline you captured in Step 1

    A successful migration yields ≥12% reduction in the primary chunk’s gzip size. Measure both gzip and brotli; brotli compression efficiency improves more significantly when repetitive module scaffolding is removed.

  3. Confirm the Network tab in DevTools Load the application in Chrome DevTools → Network tab → filter by JS → check the transfer size of the main bundle. The chunk that previously contained the full component library should now carry only the imported symbols. Use “Coverage” (Shift+Esc → Coverage) to confirm unused-bytes percentage dropped below 20% for the primary chunk.

  4. Verify Webpack 5 usedExports annotations In development mode (which does not minify), open the emitted bundle and search for /* unused harmony export */. After the migration, only genuinely exported-but-not-consumed symbols should carry this annotation — none of the tree-shaken sibling modules should appear at all.

  5. Run the full test suite

    npm run test:e2e

    Confirm zero Module not found errors or undefined runtime failures. The jscodeshift transform preserves all import specifier names so runtime behavior must be identical, but dynamic imports that construct paths from variables need manual inspection — the codemod does not rewrite dynamic import(variable) patterns.

Edge Cases and Gotchas

Named re-exports vs wildcard re-exports behave differently

A barrel using export { Button } from './Button' (named re-export) gives the bundler a static symbol list. Webpack 5 and Rollup can see exactly which symbol comes from which file and can prune unused symbols without traversing the entire barrel. The chunk inflation problem described in this page is specific to export * from './Button' (wildcard re-export), which forces full traversal.

Fix: if you want to keep a barrel for developer ergonomics, convert all export * to named re-exports. This alone can reduce chunk inflation by 8–10% without requiring a codemod across consumer code. See how to audit sideEffects in large npm packages for the audit workflow that identifies which packages still expose wildcard barrels after your own migration.

CJS interop nullifies Rollup’s static analysis

If @lib ships a CommonJS build alongside its ESM build, and Vite 5+'s pre-bundler resolves @lib to the CJS build, Rollup wraps it in a synthetic module that cannot be tree-shaken at all. The optimizeDeps.exclude entry in Step 4 forces Vite to pass the raw ESM directly to the Rollup build pipeline.

To confirm which build Vite is resolving, check .vite/deps/@lib.js in your project — if it contains Object.defineProperty(exports, or module.exports, Vite resolved the CJS build. The resolution happens because the package’s exports map provides a require condition that Vite’s pre-bundler prefers. Fix it by adding a resolve.conditions override:

// vite.config.js — Vite 5+ — force ESM resolution for CJS/ESM dual packages
export default defineConfig({
  resolve: {
    conditions: ['import', 'module', 'browser', 'default'],
  },
});

For the complete CJS-to-ESM migration pattern, see fixing tree-shaking failures with Webpack 5.

Dynamic barrel imports bypass all static analysis

Code like const { Button } = await import('@lib') re-introduces the barrel evaluation problem at runtime. Bundlers treat dynamic import() calls as opaque split points and cannot apply tree-shaking to the dynamically imported module graph — the full barrel and all of its re-exports land in the async chunk.

Fix: rewrite dynamic barrel imports to dynamic subpath imports:

// Before — pulls in the full barrel into the async chunk
const { Button } = await import('@lib');

// After — only Button.ts lands in the async chunk
const { Button } = await import('@lib/Button');

The jscodeshift transform in Step 2 does not handle dynamic imports. You must locate and rewrite them manually using a project-wide search:

grep -rn "import('@lib')" src/
grep -rn 'import("@lib")' src/

FAQ

Why does sideEffects: false in package.json not fully eliminate barrel bloat?

sideEffects: false marks individual files as safe to drop once the bundler confirms no used symbol comes from them. But a barrel that re-exports with export * from forces the bundler to traverse all re-exported modules before it can determine which symbols are used. The bundler must evaluate the full aggregation chain before applying dead-code elimination, so even with the flag set you pay the traversal cost and risk pulling in sibling modules.

Can I keep a barrel index for developer ergonomics while still tree-shaking?

Yes — use named re-exports (export { Button } from './Button') rather than wildcard re-exports (export * from './Button'). Named re-exports give the bundler a static symbol list so it can prune unused sibling modules. However, an explicit package.json exports map that surfaces individual subpaths gives bundlers the most reliable static analysis surface and is the preferred production approach.

Does replacing barrel imports break TypeScript path aliases?

Only if you remove the alias without adding a replacement. Add subpath entries to tsconfig.json paths alongside the package.json exports map — the two configurations must mirror each other. The jscodeshift transform rewrites import call sites but does not touch tsconfig.json, so you must update paths manually after running the codemod.