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.


Pages in this section



Back to Tree-Shaking & Bundle Optimization