March 19, 2025
Node made some huge steps forward recently by releasing require(esm)
to each of the long-term support versions (other than the soon to fall out of LTS 18.x). Awesome work by @joyeecheung getting it over the line!
This is awesome work and unblocks a huge amount of packages on the migration path from CommonJS to ES modules 🎉
So let's have a look into what's next and how the e18e community is trying to help!
require(esm)
One of the main blockers in the CJS vs ESM story has been interop. You could import CJS from within an ES module, but could not require
an ES module from within a CJS module.
This left us with two options at the time for a migration path:
- Change much of the code to be
async
, such that we can use a dynamicimport
(which does work in CJS modules) - Change all CJS dependencies of our ES module package to be ES modules
The ideal is the latter, that everyone uses ES modules. However, this obviously isn't feasible since not all packages are maintained and it'd be a crazy amount of work to replace them.
So we were blocked...
That is until require(esm)
came along! This basically means we can require
an ES module inside a CJS module now. CJS packages can consume ES module packages and vice versa. A huge move forward and unblocks us all to get back on the migration path!
For example:
// file: foo.cjs
// chai is esm only, but this now works!
const { expect } = require('chai')
Types of package
As part of this migration, we have three types of package to deal with:
- ESM packages
- CommonJS packages
- Dual packages
Migrating a CommonJS package
In most cases, this is as simple as doing the following steps:
- Set
type
to"module"
inpackage.json
- Update all imports to include file extensions
- Update sources to use
export
/import
syntax
For example, in package.json
:
{
"name": "my-package",
"type": "module"
}
And updating the imports:
// before
import './foo'
// after
import './foo.js'
It is preferred that you have file extensions, but if you want to expose extensionless imports to your consumers, you can use an export map:
{
"exports": {
"./foo": "./foo.js"
}
}
Migrating a dual package
Dual packages are basically a necessary evil if you want to support both CJS and ESM before require(esm)
was available and don't want to force your CJS users to use an async dynamic import.
These packages work by having two copies of the sources and the types.
Fortunately, this doesn't increase the package size much since it will be compressed anyway. However, it does increase the on-disk size (once extracted by npm
).
Most people shipping TypeScript packages like this will be using a tool like tsup or tshy.
Those shipping JavaScript often just use a bundler like esbuild to create two bundles (or one and the sources).
To migrate from these setups, we mostly need to do the same steps as migrating a CommonJS package from above.
Some maintainers may still want to use their choice of tool/bundler, so in those cases we can configure the tool to no longer output CommonJS.
For example, in tsup
:
{
"name": "my-package",
"type": "module",
"tsup": {
"format": ["esm"]
}
}
How the community is helping
Within the e18e community, we have been tracking the migration from CJS to ESM in an issue for some time.
It is a crazy amount of work for us to try migrate every possible package, so we have opted for the approach of helping migrate high impact tools, starter kits, frameworks and what not. This should hopefully help others to follow suit and give plenty of example migrations to work from.
Who has migrated so far?
We've already seen a huge amount of packages make the jump. Here are just a few (some of which were assisted by e18e, and some not):
- chai and all official plugins
- tinyspy
- tinybench
- tinyexec
- vueuse
- @pinia/nuxt (in progress)
- clack
- eslint-plugin-lit
- eslint-plugin-wc
- eslint-plugin-svelte
- eslint-plugin-github
- picospinner
This is just a subset of the packages we've seen migrate, many more PRs are still in progress by contributors and maintainers alike.
Migrating ESLint plugins
One place we can easily contribute to this effort is the migration of ESLint plugins.
ESLint already imports plugins under the hood, and so can support them in CommonJS or ES module format.
Migrating an ESLint plugin to ES modules will mean dropping support specifically for consumers who have flat configs written as CommonJS in a version of Node less than 20.x.
This is because flat configs in >=20
will have require(esm)
available, and legacy configs (.eslintrc
) will use import
.
Migrating starter kits and frameworks
Another high impact place to help with this migration is in starter kits and frameworks. Many projects are created from these templates, so migrating them to ES modules will mean all new consumers automatically have the right setup.
We haven't yet started collaborating with these projects on this yet, but it is high priority in the pipeline.
Get involved
If you maintain a package and want some help migrating, let us know! Many of the community would be happy to chip in.
Similarly, if you want to help migrate packages with us, come say hi.
Join our discord and let us know you want to help!