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:
-
Tree-shaking defeat — older bundlers (and even modern ones in some cases) can’t easily tree-shake a
export *barrel. ImportingButtonfrom 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. -
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.
-
Circular imports —
Aimports from barrel; barrel re-exportsBwhich 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.tsexporting 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.tsmay 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 properexportsmap + 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:
- Audit — find your large barrels. Each one is a candidate.
- Replace usage — use a codemod (
jscodeshiftorts-morph) to rewriteimport { X } from "./barrel"toimport { X } from "./barrel/X". - Delete the barrel (or keep as a minimal public API).
- 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-exports —export *re-exports everything including types; explicitexport { X }is tree-shake-friendlier with conservative bundlers.- Circular import bug pattern —
index.tsimports fromA.ts;A.tsimportsBfromindex.ts. At runtime,Bmay beundefinedbecauseindex.tshasn’t finished evaluating. Symptom: “TypeError: B is not a function” only in some load orders. - Tree-shaking +
sideEffects: falseis essential — without it, bundlers conservatively keep barrel-re-exported modules even if unused. - Type-only barrels —
export type *is OK and tree-shake-free (types vanish at runtime). import typefor 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.tsplus per-component subpath exports (exportsfield) is the published shape — consumers get tree-shaking either way.” - “Lint-enforce ‘no internal barrel imports’ with
no-restricted-importspatterns. Without enforcement, the codebase drifts.” - “Audit bundle analyzer output for surprises — a giant library import often traces to a barrel pulling everything.”
Cross-references
- Tree-shaking mechanics: ../15_performance/04_tree_shaking.md
- Bundle analysis: ../15_performance/03_bundle_analysis_and_code_splitting.md
- Module formats +
exportsfield: ../09_build_tools/03_module_formats.md - Linting setup: 06_linting_and_formatting.md
Further reading
- Marvin Hagemeister — “Speeding up the JavaScript ecosystem” (covers barrel impact): https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-7/
- Next.js — “Optimize Package Imports”: https://nextjs.org/docs/app/api-reference/next-config-js/optimizePackageImports