Barrels (index.ts) — When Helpful, When Harmful

6 min read source

Barrels (index.ts) — When Helpful, When Harmful

TL;DR

A barrel is an index.ts (or index.js) file that re-exports many siblings: export * from "./Button"; export * from "./Input";. Consumers do import { Button, Input } from "./components" instead of importing per file. Convenient ergonomically, dangerous at scale: barrels can defeat tree-shaking, slow down builds, cause circular imports, and hide the real dependency graph. The senior position: use barrels only at well-defined module boundaries (a package’s public API), not inside features.

Interview Q&A

Q: Show me a barrel.

A:

// src/components/index.ts (barrel)
export * from "./Button";
export * from "./Input";
export * from "./Modal";
export * from "./Tooltip";
// ... 50 more

// Consumer
import { Button, Modal } from "@/components";   // one import line

Without the barrel:

import { Button } from "@/components/Button";
import { Modal } from "@/components/Modal";

Slightly less convenient. But avoiding the barrel has real perf and correctness wins.

Q: What’s wrong with a big barrel?

A: Three concrete problems:

  1. Tree-shaking defeat — older bundlers (and even modern ones in some cases) can’t easily tree-shake a export * barrel. Importing Button from a 50-component barrel may pull all 50 (or at least force their imports to evaluate) if the bundler can’t prove side-effect freedom.

  2. Build slowdown — TypeScript and bundlers re-process every re-exported file every time the barrel is imported anywhere. A 200-export barrel imported from 50 places = thousands of redundant lookups.

  3. Circular importsA imports from barrel; barrel re-exports B which imports from barrel → cycle. Bundlers handle most cycles, but they cause subtle bugs (undefined exports at runtime) and slower compiles.

Q: When are barrels OK?

A:

  • At a package’s public API. packages/ui/src/index.ts exporting the package’s surface is fine — that’s the place external consumers import from. Inside the package, deep imports.
  • For framework-required entry points. A Next.js route/index.ts may be needed.
  • For very small modules (~5 exports) where the bundler can clearly tree-shake.

When are they bad?

  • Large component libraries (>30 exports) — performance hit.
  • Inside features — circular import risk.
  • Around heavy modules (charts, editors, third-party wrappers) — pulling the barrel can balloon bundle size.

Q: How does deep importing fix it?

A: Consumers import from the file, not the barrel:

// Bad — barrel
import { Button } from "@/components";

// Good — deep
import { Button } from "@/components/Button";

The bundler now sees a single, precise import. Easy tree-shake, no circular risk.

Cost: longer import lines. Tools (auto-import in IDE, eslint-plugin-import rules) make it manageable.

Q: Show the Next.js / common eslint rule.

A: eslint-plugin-no-barrel-imports-style rule (or write your own):

// eslint.config.js
{
  rules: {
    "no-restricted-imports": ["error", {
      patterns: [
        {
          group: ["@/components", "@/features/*"],
          message: "Use deep imports — `import { X } from '@/components/X'` — barrels cause tree-shake/build issues",
        },
      ],
    }],
  },
}

Allows the package public APIs (@my/ui) while forbidding internal barrels.

Q: Library publishing — barrel as public API?

A: Yes. The index.ts of a published package is the contract with consumers. Add sideEffects: false in package.json and ensure named re-exports (no * for finer tree-shaking with some bundlers).

// packages/ui/src/index.ts
export { Button } from "./Button";
export type { ButtonProps } from "./Button";
export { Modal } from "./Modal";
// ... explicit re-exports

Plus per-component subpath exports for deep importing:

// packages/ui/package.json
"exports": {
  ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.ts" },
  "./Button": { "import": "./dist/Button.mjs", "types": "./dist/Button.d.ts" },
  "./Modal": { "import": "./dist/Modal.mjs", "types": "./dist/Modal.d.ts" }
}

Consumers can import { Button } from "@my/ui" or import { Button } from "@my/ui/Button" — the second is unambiguously tree-shakeable.

Q: What about MUI / Chakra / Mantine — they have huge barrels.

A: Yes, and they all document the workaround:

  • MUI v4 had a notorious barrel — import { Button } from "@mui/material" historically pulled the whole library. v5 fixed this with proper exports map + sideEffects, plus you can deep-import (import Button from "@mui/material/Button").
  • Chakra has the same — import { Button } from "@chakra-ui/react" is fine in modern versions thanks to proper packaging.

The library’s package.json + bundler config is what determines whether the barrel hurts. Audit your bundle (see ../15_performance/03_bundle_analysis_and_code_splitting.md) — if MUI is suspiciously large, deep-import or upgrade.

Q: Auto-imports in IDEs — do they generate barrel or deep imports?

A: VS Code’s auto-import picks the import path based on the shortest match that exports the symbol. If there’s a barrel, it suggests the barrel; if not, the deep path.

Configure VS Code (typescript.preferences.importModuleSpecifier: "relative" | "non-relative" | "shortest") to bias toward deep paths. Or remove barrels you don’t want suggested.

Q: How do you migrate away from barrels in an existing codebase?

A:

  1. Audit — find your large barrels. Each one is a candidate.
  2. Replace usage — use a codemod (jscodeshift or ts-morph) to rewrite import { X } from "./barrel" to import { X } from "./barrel/X".
  3. Delete the barrel (or keep as a minimal public API).
  4. Add lint rule preventing barrel imports for that path.

For libraries you publish, ensure the change is non-breaking — keep the barrel exporting everything, just route consumers to deep imports for tree-shaking benefits.

Q: Aren’t barrels good for refactoring (move a file, just update the barrel)?

A: Slightly, but:

  • IDE refactor + path-aware import tools handle file moves correctly even without barrels.
  • The “central reorganization” win is rare in practice; you move components mostly within their feature.
  • The cost (perf, builds, circulars) outweighs.

The “barrels make refactors easier” argument was stronger 5 years ago. Modern tooling closed the gap.

Gotchas / edge cases

  • export * vs explicit re-exportsexport * re-exports everything including types; explicit export { X } is tree-shake-friendlier with conservative bundlers.
  • Circular import bug patternindex.ts imports from A.ts; A.ts imports B from index.ts. At runtime, B may be undefined because index.ts hasn’t finished evaluating. Symptom: “TypeError: B is not a function” only in some load orders.
  • Tree-shaking + sideEffects: false is essential — without it, bundlers conservatively keep barrel-re-exported modules even if unused.
  • Type-only barrelsexport type * is OK and tree-shake-free (types vanish at runtime).
  • import type for type-only imports — bypasses runtime concerns entirely.
  • Storybook auto-discovery may rely on barrels — verify Storybook config when removing.

What a senior is expected to say

  • “Barrels are convenient and dangerous. The right place for a barrel is a package’s public API, not internally within features.”
  • “Inside an app, deep imports are the default — the bundle is smaller, builds are faster, circular import risk drops.”
  • “Library index.ts plus per-component subpath exports (exports field) is the published shape — consumers get tree-shaking either way.”
  • “Lint-enforce ‘no internal barrel imports’ with no-restricted-imports patterns. Without enforcement, the codebase drifts.”
  • “Audit bundle analyzer output for surprises — a giant library import often traces to a barrel pulling everything.”

Cross-references

Further reading