ESM
What is ESM?
ESM (ECMAScript Modules) is the official standard module system for JavaScript. While our current codebases use CJS (CommonJS) modules, the module system originally introduced by Node.js, the JavaScript ecosystem is moving toward ESM as the standard.
One key challenge is that CJS and ESM are not directly compatible with each other, which makes transitioning to ESM increasingly important. The good news is that ESM provides compatibility with CJS, allowing you to use CJS modules within ESM code. Unfortunately, the reverse is not possible and you cannot use ESM modules in CJS code.
You will mostly be already familiar with the ESM syntax if you have used import
and export
statements in your code. At the moment our current setup instructs TypeScript to convert these ESM-style imports and exports into CJS require
and module.exports
statements.
Existing challenges
There are currently three key factors that complicate a transition to ESM:
1. File extensions
ESM requires explicit file extensions in import statements, which is not the case with CJS. This means that we need to update all our import statements to include the correct file extensions. It may look a little unusual especially in a TypeScript codebase, but it is a requirement of ESM.
// CJS
import { module } from './imported-module';
// ESM
import { module } from './imported-module.js';
While this is a simple change, it requires us to update all our import statements across the codebase. It also forbids us from using the index.js
import convention, which is commonly used in CJS.
For example, in a directory structure like this:
imported-module/
├── index.js
└── other-file.js
We can no longer rely on implicit index.js
resolution:
// CJS
import { module } from './imported-module';
// ESM
import { module } from './imported-module/index.js';
2. skuba-dive/register
Our current setup uses skuba-dive/register
to allow us to simplify our import statements and avoid needing to use deep relative paths.
Instead of importing a module like this:
import { module } from '../../imported-module';
We can import it like this:
import { module } from 'src/imported-module';
However, skuba-dive/register
relies on module-alias
which is not compatible with ESM. This means that we need to find a new way to handle module aliases in ESM.
3. Jest
Our current setup use Jest for testing, which is still not fully compatible with ESM as of version 30. This is a significant blocker as switching to ESM would require us to switch to a different testing framework or wait for Jest to become fully compatible with ESM.
Migrating to ESM with Jest would require significant changes to our codebase, such as updating all imports to to be dynamic imports in order to use mocks, and to change all jest.mock
calls to use jest.unstable_mockModule
instead.
Jest has always been a pain point for us, as we currently apply a number of custom workarounds to make it work nicely with TypeScript.
Transitioning to ESM
We will transition to ESM over several major versions, starting with the following steps:
1. Add file extensions to import statements via lint rules
We will replace existing import statements to include the .js
file extension upfront. This still works with CJS and will reduce the number of changes required when we eventually switch to ESM.
As Jest does not support file extensions in import statements, we will apply a custom moduleNameMapper
which strips the file extensions when running tests.
This shouldn’t cause any issues, but out of caution, we will release this as a new major version.
2. Replace skuba-dive/register
with subpath imports
We will replace skuba-dive/register
with subpath imports, a native solution supported by both TypeScript and Node.js.
The subpath imports feature allows us to define custom paths in package.json
, enabling us to import modules using simplified paths without needing to use deep relative paths.
package.json:
{
"name": "my-package",
+ "imports": {
+ "#src/*": {
+ "types": "./src/*", // This helps our local IDE to resolve the types
+ "import": "./lib/*",
+ "require": "./lib/*"
+ }
+ }
}
This will require some changes to our base skuba/config/tsconfig.json
and your local tsconfig.json
files.
Our base skuba/config/tsconfig.json
will update moduleResolution
from node
to node16
and module
from commonjs
to node18
:
{
"compilerOptions": {
"incremental": true,
"isolatedModules": true,
- "moduleResolution": "node",
+ "moduleResolution": "node16",
+ "module": "node18",
"resolveJsonModule": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"extends": "tsconfig-seek"
}
Your local tsconfig.json
files will require a baseUrl
and rootDir
to help TypeScript resolve the subpath imports correctly:
{
"compilerOptions": {
"baseUrl": ".",
+ "rootDir": ".",
- "paths": {
- "#src/*": ["src/*"]
- }
},
"extends": "skuba/config/tsconfig.json"
}
This allows us to import modules like this:
import { module } from '#src/imported-module';
3. Switch to Vitest
Finally, we will switch to Vitest as our testing framework. Vitest is a modern testing framework that is fully compatible with ESM and provides a similar API to Jest, making it easier for us to transition.
We will apply a community codemod to help with the transition, but it will likely require some manual changes to our tests. Vitest also provides TypeScript support out of the box, which means we won’t need to apply any custom workarounds like we do with Jest.