Migrate a Node.js project to ESM
To use an ESM Node.js module in your own, you need to use import
. But node can only use import inside ESM modules, so you need to convert your own CommonJS modules to ESM.
Writing import
and converting it to require()
with Babel does not count!
Otherwise you will get an error like this:
SyntaxError: Cannot use import statement outside a module
So, how do you migrate your code to use import
instead of require()
?
First, use at least Node.js 12.17 so that ESM modules are available without the --experimental
flag.
Next, get your own code to run as an ESM module. Either rename your files to the .mjs
extension or add type: module
at the top level in package.json
.
With the first option only the renamed files run as ESM modules. With the second option, you need to rename to .cjs
the files you wish to run as CommonJS modules.
The next issue is that you cannot use the global require()
inside an ESM module. Node.js will exit and print an error like:
ReferenceError: require is not defined in ES module scope, you can use import instead
So in order to be able to import
an ESM-only module, you need to replace every single require()
call, even those that import CommonJS modules.
For top-level require()
, there’s just one issue that is likely to bite you: while require()
supports a directory name, import only accepts the path to a single file. When use require()
with a directory name, require()
imports the file named index.js
in that directory. So you need to replace
const myModule = require('../directory');
with
import myModule from '../directory/index.js';
If the directory contains a package.json
file, require will look at the package.json
in that directory to find which file to import, so you need to replace the directory name with the file indicated in package.json
. Usually it’s the file in the main
field.
If you use dynamic requires (hopefully you are in the minority, but for some framework-like tools it is the sad truth as they import different modules depending on the environment), you can replace them with dynamic import()
.
The problem is that import()
is async which will make your function change return type to a Promise. This propagates the async
up the function call chain, and can lead to pretty invasive API changes depending on how your code uses dynamic require()
. You might be forced to use top-level await, but ESLint does not support it out of the box.
To work around this issue you can use createRequire()
. This function takes the current module URL (import.meta.url
) and returns a function that you can use to synchronously include other modules. This avoids having to propagate async functions everywhere.
The final issue is often related to dynamic imports and it is the usage of __dirname
variable. In CommonJS modules, it gives you the absolute path of the directory containing the currently executing file, so it is useful to be able to include files relative to the current file no matter from which directory the node process starts. __dirname
does not work in ESM modules. You will get an error like:
__dirname is not defined
To solve this you’ve got two possibilities. Both use import.meta.url
. This is a special variable that’s available inside ESM modules which contains the URL of the module file. The best solution in most cases is to replace the file path constructed with __dirname
with an URL constructed with import.meta.url
, as many file APIs have been updated to accept an URL. This is less verbose. The alternative is to build the exact equivalent of __dirname
from import.meta.url
. The recipe for doing so is the first search result on many search engines. To avoid an error in ESLint with import.meta.url
you need to change the language level to 2021 in the ESLint configuration.
The three main problems when converting a CommonJS module to ESM are directory imports, require()
calls and __dirname
usage. If your code base uses a lot of require()
calls to dynamically constructed paths, you might be in for a lot of work. Good luck!