paint-brush
When are Barrel Exports Harmfulby@castarco
601 reads
601 reads

When are Barrel Exports Harmful

by Andres Correa CasablancaNovember 18th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

The barrel exports saves us from having to remember too many import paths, and from explicitly export every symbol from our sub modules inside the package... But sometimes can be harmful. When and why, then?

Company Mentioned

Mention Thumbnail
featured image - When are Barrel Exports Harmful
Andres Correa Casablanca HackerNoon profile picture

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 actually 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 click-bait. 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 learnt 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 initialization, 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 really 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 times types have to be explicitly imported in order to have any effect at type-checking time, there are some interesting cases that 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 auto-generated code. One interesting case of this is GraphQL to TypeScript code generation, and how this is integrated with the amazing Mercurius library (made by some of my colleagues at NearForm! 😜).

It’s not a coincidence that I mention auto-generated code and Mercurius: I actually found myself having to deal with a barrel that was exporting one of these auto-generated 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 really perform a runtime import because it’s totally 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. It is true that the compiler can improve in the future, but I wouldn’t hold my breath for now.

Indirect problems

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 we should prefer to not have footguns at our 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.

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.


Also published here.