The barrel exports pattern is described in Basarat’s book (TypeScript Deep Dive), and looks like this:
/// index.ts
export * from "./a";
export * from "./b";
// [···]
export * from "./z";
It’s pretty common, most people use it, and I don’t blame them: it’s very useful. It saves us from having to remember too many import paths, and from having to explicitly export every symbol from our submodules inside the package.
So… yes, I admit it, the title of this article was pure clickbait. I don’t think this pattern is always harmful, only sometimes. When and why, then?
It’s almost always about coupling
Side effects at module load time
Although not likely to happen (because most of us have learned the hard way that it’s a bad idea to perform side effects at module load time), that’s the simplest problematic case we can explain.
When we “need” to perform some side effects at load time (whatever the reason: singletons, resource initialisation, monkey patching…) in one of the submodules that is star-exported on our “barrel”, we’ll be unable to avoid them when importing anything that is completely unrelated from that same barrel.
As an example, if we just wanted to import a utility function, we might be triggering some resource initialization that had no relation to that task (affecting performance for no good reason), or performing unintentional monkey patching, leading to potential hard-to-debug problems.
/// lib/index.ts
export * from "./a";
export * from "./b";
export * from "./c";
/// lib/a.ts
export const greet = (name = "stranger") => console.log(`Hello ${name}!`);
console.log("Loading module a"); // Side effect
/// lib/b.ts
export const square = (x: number) => x * x;
console.log("Loading module b"); // Side effect
/// lib/c.ts
export const cube = (x: number) => x * x * x;
console.log("Loading module c"); // Side effect
/// main.ts
import { greet } from "./lib";
greet("World");
/// main.js's output:
// --------------------------------------------------------------------
// Loading module a
// Loading module b // <- We didn't want this
// Loading module c // <- We didn't want this
// Hello World!
”Type effects” at type checking time
You might be thinking that the previous case is not that problematic, because no side effects are performed in most code you see every day (consider yourself lucky!), but that’s not where the story ends.
First of all, I’m probably going to abuse the term “type effect” on the following lines (I suspect this term already has some different meaning attached, but given that I don’t have a better word, I’ll stick to it for now), I ask for your forgiveness in advance… and if you know of a better way to describe it, please feel free to reach out to me and tell me!
While most of the time types have to be explicitly imported in order to have any effect at typechecking time, some interesting cases don’t work like that, module augmentation and ambient modules (both rely on the same construct “declare module”):
declare module "some_external_module" {
// typing stuff in here
}
What this does is to overwrite or augment the types exposed by the pointed module, and can be used (for example) when relying on autogenerated code. One interesting case of this is GraphQL to TypeScript code generation, and how this is integrated with the Mercurius library.
It’s not a coincidence that I mention autogenerated code and Mercurius: I found myself having to deal with a barrel that was exporting one of these autogenerated files. That barrel was inside a supposedly generic types library (in the context of a monorepo)… and because of it, it was “leaking” types from one federated “gateway” into another one that was supposed to expose a completely unrelated object graph. Untangling that wasn’t fun.
Generated code can also suffer
When some of our modules only contain types (but no runtime symbols), we probably want that the generated code doesn’t perform a runtime import because it’s unnecessary.
If we use import type
instead of just import
, or our import statement is
explicitly importing something that it’s just a type without a runtime
counterpart (basically, not enum
nor class
), then we can expect tsc
to
optimize away that import from the generated JS code.
Sadly, tsc
is not that smart when it comes to “start-exports”, and we won’t be
able to optimize it away. The compiler can indeed improve in the future, but I
wouldn’t hold my breath for now.
Indirect problems
Circular references
The barrel pattern makes it easier to introduce accidental circular references. It is true that the fault, in this case, doesn’t really fall on the pattern, but I prefer to not have footguns at my disposal.
I’ve seen too many times how some people decide to import “symbols” from
“sibling” modules through the index.ts
file instead of directly addressing the
source modules; the error here is not the barrel itself (because it’s thought to
be consumed from “outside”), but consuming “internal” code as if it was an
external library.
Some dev tools become slower
When we use barrel exports in our code, static analysis becomes more complex. Some tools have explicit support for them, but others strugle to provide good results or to give them in an acceptable amount of time.
It’s worth noticing that not only static analysis tools can perform worse when in presence of barrel exports, test runners such as Jest can also see their performance badly hurt, affecting our productivity, and the associated costs of our CI pipelines.
Conclusion
Barrel exports are a great tool if used correctly, but there’s great risk of abusing them. As with any other technique, we should be aware of its associated trade-offs and under which circumstances they can become problematic, so we can consider these factors when deciding whether to use them or not.