BattlefyBlogHistoryOpen menu
Close menuHistory

How to keep grubby hands off your ECMAScript modules internals

Ronald ChenMay 16th 2022

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.

Avatar used in multiple places

Initially we have both post and profile components use the same avatar component.

user/
├── avatar.js
└── profile.js
post/
└── author.js
// user/avatar.js

export default () => {
  ...render avatar
}
// user/profile.js

import avatar from './avatar.js'

export default () => {
  ...use avatar
  ...render profile
}
// post/author.js

import avatar from '../user/avatar.js'

export default () => {
  ...use avatar
  ...render profile
}

Where it all falls apart

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.

user/
├── avatar.js
├── avatar.spec.js
└── profile.js
post/
└── author.js

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.

user/
├── avatar/
│   ├── index.js
│   ├── some-tests.spec.js
│   └── some-more-tests.spec.js
└── profile.js
post/
└── author.js

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.

Refactoring to a public API

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.

user/
├── avatar/
│   ├── index.js
│   ├── some-tests.spec.js
│   └── some-more-tests.spec.js
├── index.js
└── profile.js
post/
└── author.js
// user/index.js

export { default as avatar } from './avatar/index.js';
export { default as profile } from './profile.js';

Then post can consume the public API from user.

// post/author.js

import { avatar } from '../user/index.js'

export default () => {
  ...use avatar
  ...render author
}

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.

Leave the heavy lifting to linters

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.

// .eslintrc.json

...
"import/no-internal-modules": ["error", {
  "allow": ["**/index.js"]
}],
...

Do you have excellent ES module hygiene? We'd like to hear from you! Battlefy is hiring.

2022

Powered by
BATTLEFY