ES modules have been available for a while now. One might have used it in Webpack, TypeScript, Node.js (12+) or even directly in the browser with Vite.
But ES modules doesn't give any guidance on how one should organize modules. This can lead to chaos as it allows any module to import any other module. Then later when one attempts to modify an existing module, it becomes an impossible task with a rat's nest of inter-module dependencies.
The solution is to offer a public API for each ES module, and only allow ES modules to depend on the public API. And as a bonus, we can use linters to make this practise sustainable.
Let's see this in action with an example. In the example we'll be talking about frontend components, but this equally applies to anywhere ES modules is used.
Initially we have both
profile components use the same
Things are going well, new features are added, but it starts to get out of hand and we need to add some tests. We reach for Jest for its snapshot testing. Initially we add a test for the
But we soon realize the the avatar component test is getting too long. We need to split up the test into multiple files to keep it readable. No problem, we'll just create an avatar folder.
We diligently update the import in
import avatar from './avatar.js' to
import avatar from './avatar/index.js'.
Aside: Sadly with ES modules we must explicitly use file extensions. We also can't use
import avatar from './avatar'as that is non-standard Node.js specific resolution logic (which might be configurable in the future).
But wait, we also need to update the import in
import avatar from '../user/avatar.js' to
import avatar from '../user/avatar/index.js'. This doesn't make any sense. Why should the internal reshuffling of
avatar cause external consumers have to update the import path? This is a code smell.
post/author.js knew too much about the implementation details of
This is why offering a public API is important. Had we offered a public API which
post/author.js could depend on, then moving
avatar around shouldn't have any visible effect.
Let's refactor to put in a public API for the
user module. To do this we add
user/index.js and re-export modules we want to be part of our public API.
post can consume the public API from
avatar is implemented or named internally to
user does not matter as long as we maintain a stable export in
Once a public API is defined, things become much more easier to organize. Decisions can be made on whether certain things are public/private or their own module. Testing becomes more obvious as well, as we should only test the public API.
Enforcing modules only use each other's public API is surprisingly easy with the ESLint import plugin. We configure the
no-internal-modules rule, and allow
index.js which is the public API.
Do you have excellent ES module hygiene? We'd like to hear from you! Battlefy is hiring.