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 post
and profile
components use the same avatar
component.
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 avatar
component.
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 user/profile.js
for avatar
from 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 post/author.js
from 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 avatar
.
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.
Then post
can consume the public API from user
.
Now how avatar
is implemented or named internally to user
does not matter as long as we maintain a stable export in user/index.js
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.