Tips for writing ES modules in Node.js
Recently I’ve been working a bit with ES modules within Node.js. I’ve found documentation and blog posts to be a little patchy with some detail, but not all, and docs all over the place. So this post is essentially everything I’ve learnt about using ES modules within Node.js, transitioning from CommonJS, all in one place.
EcmaScript Modules were defined in the ES2015 specification introduced in June 2015. They were first introduced to Node.js in September 2017 behind the --experimental-modules
flag and is due be supported without the flag in 2020. There are a number of benefits of ES modules, namely
- A universal module system between browsers and Node.js.
- Static code analysis enabling tree-shaking of ES modules using bundling frameworks like Rollup and Webpack. Ship just the code used in your application.
Node.js will maintain a level of compatibility between CommonJS and ES modules. This post helps to provide useful tips on writing ES modules, for those used to writing CommonJS modules.
There are a number of differences between CommonJS and ES modules that need to be understood. Key differences are
- ES modules use
.mjs
files, CommonJS uses.js
files. Node.js uses the file extension to determine which module system to load. - ES modules are statically imported vs CommonJS modules are dynamically imported.
- Node.js’s resolution algorithm will import the
.mjs
version of a module before a.js
version, if two versions with the same name exist. - You can import modules using a URL path
- CommonJS brings modules in as a
module
object. Essentially a normal JavaScript Object you can mutate and basically treat as a normal object. ES modules it doesn’t bring the module in as a normal JavaScript Object, with some limitations on how you interact with an ES module.
Below are some tips for writing ES modules in Node.js if you’re used to CommonJS modules.
Statically importing modules
The import module from 'module'
syntax brings in the module globally and read-only. This changes the way you use them in a few ways.
- All of your
import module from 'module'
statements must be at the top of your module/script - You cannot mix and match CommonJS and ES module syntax in the same module/script. Basically if it’s a
.mjs
file, make sure there arerequire()
statements in your code. - An ES module can import a CommonJS module. Be aware of how the relevant modules export methods and properties and the way they are imported are subtly different in each case. To expand on the example in this post, if you have the following code
//foobar.js
function foo() {
return 'bar';
}function bar() {
return 'foo';
}module.exports.foo = foo;
module.exports.bar = bar;
You could reference it in a CommonJS module like this
//app.js
const { foo, bar } = require('foobar');
console.log(foo(), bar());
and the equivalent in ES modules to reference the same CommonJS module, foobar.js
, would be
//app.mjs
import { foo, bar } from 'foobar'
console.log(foo(), bar());
Whilst the syntax looks similar, what is happening is slightly different. In the CommonJS example, the line const { foo, bar } = require('foobar')
is using Object destructuring to extract the foo
and bar
objects from require('foobar')
. In the ES module example, the import { foo, bar } from 'foobar'
syntax is directly importing the named exports foo
and bar
.
The CommonJS syntax is basically doing this.
const foobar = require('foobar')
const { foo, bar } = foobar
console.log(foo(), bar());
The CommonJS syntax is using two steps to create the foo
and bar
objects vs the ES module syntax which does it in one step.
Key to understanding how to use CommonJS modules within ES modules is to understand how default and named exports work. To expand on the above example, if you used the following syntax
//app.mjs
import foobar from 'foobar'
const { foo, bar } = foobar
console.log(foo(), bar());
You would get an error. The reason is that the CommonJS module, foobar.js
, does not have a default export. To bring the relevant methods in from foobar.js
you would either bring them in by name, as the initial working example does, or you would import them under a single namespace like this.
//app.mjs
import * as foobar from 'foobar'
const { foo, bar } = foobar
console.log(foo(), bar());
The *
operator references all the methods and properties exported by foobar.js
and the as foobar
creates a module namespace called foobar
that you can use to reference individual methods.
The import module from 'module'
syntax can only be used when the module being imported has a default export.
- You can’t reference any methods, properties or objects of the imported module within the
import
statement. So code like this
const version = require('./package.json').version
needs to be rewritten like this
import * as pkg from `./package.json`
const version = pkg.version
- ES modules are read-only, so you can’t mutate them once imported. Syntax like this which works in CommonJS.
const module = require('./module')
const func = () => { return 'this is a function' }
Object.assign(module, func)
versus the ES module equivalent
import module from 'module'
const func = () => { return 'this is a function' }
Object.assign(module, func)
The CommonJS example will work, the ES module example will fail. If you use this pattern you’ll need to figure out another way to achieve what you want.
Exporting Modules
The way you export functions, methods and objects with ES modules changes slightly. The biggest change is the introduction of an explicit default export with the export default
syntax.
With ES modules default and named exports can co-exist. For example.
//foobar.mjsfunction foo() {
return 'bar';
}function bar() {
return 'foo';
}export { foo }
export default bar
The example above has a named export foo
and a default export bar
, so to import them you can use the code below
//app.mjs
import { foo } from 'foobar'
import foobar from 'foobar'console.log(foo()); // 'bar'
console.log(foobar()); // 'foo'
This example imports foo
as a module called foo
and imports bar
as a module called foobar
. A more useful example might be where you export a class as the default export, and helper methods as named exports on their own.
If you tried to do this with CommonJS modules and the module.exports
syntax, the exports would override each other. For example
//app.js
function foo() {
return 'bar';
}function bar() {
return 'foo';
}module.exports.foo = foo;
module.exports = bar;
And the corresponding import with require()
const { foo } = require('foobar'); //will create an error
const foobar = require('foobar')console.log(foo()); // Error!!
console.log(foobar()); // 'foo'
In the above CommonJS example, in the line module.exports = bar
the previous object module.exports.foo
is overwritten and isn’t exported so will error if referenced in an import.
Another useful feature of ES modules exports is the ability to export directly from an imported module. For example you can write the following
export { foo } from 'foobar'
Which is the ES module equivalent of the CommonJS code below.
const { foo } = require('foobar')
module.exports.foo = foo
On the whole, ES module exporting is more flexible and explicit.
Working with files in ES modules
The way you reference and work with files in ES modules changes.
The __filename
and __dirname
statments don’t work in ES modules. For the equivalent operation you need to use import.meta.url
syntax. So code that would be written like this in CommonJS
console.log(`Current file is ${__filename}`);
console.log(`Current directory is ${__dirname}`);
Would need to be rewritten like this in ES modules
const currentFile = import.meta.url;
const currentDirectory = new URL(import.meta.url).pathname;console.log(`Current file is ${currentFile}`);
console.log(`Current directory is ${currentDirectory}`);
The key difference is that import.meta.url
brings the filename in a URL format, so using file://
to reference the file. This syntax is currently in stage 3 of the TC39 process and is a JavaScript implementation, not Node.js specific. So code written using this syntax can be used in both the browser and Node.js.
In general you should start using the new Node.js implementation of the URL WHATWG URL API, defined in the Node docs here, for file and path operations. The Node.js Path and File modules still work with ES modules, but you can also use the new URL implementation for working with files. At this point ES module syntax in Node.js only works with URLs using file://
, though the spec does support all URLs, so hopefully down the line we’ll be able import modules via a remote URL in Node.
Dynamic Imports with ES modules
Whilst ES modules are statically imported, there is the ability to dynamically import ES modules using the import()
syntax. This syntax is also at stage 3 of the TC39 process, and is supported by Node.js behind the --experimental-modules
flag.
The import()
syntax is function-like, and can be used very similarly to the require()
syntax. It dynamically imports ES modules, versus require()
which can only import CommonJS modules.
The reality is that you don’t often need to dynamically load JavaScript modules, a common use case is the hot loading of generated code within an application, but in most instances where you use a module, statically importing it is preferable.
The key thing to be aware of with the import()
syntax is how to reference the exported methods and objects. If you have a module like below
//foobar.mjs
function foo() {
return 'bar';
}function bar() {
return 'foo';
}export { foo, bar };
It has two named exports, foo
and bar
, which can be imported dynamically like this
//app.mjsconst currDir = new URL(import.meta.url).pathname
const fooBarLocation = new URL(currDir, '/foobar.mjs')
const foobar = import(fooBarLocation) //full path to foobar.mjs fileconsole.log(foobar.foo(), foobar.bar());
You need to reference the full path to the module you are importing dynamically. The above example uses the new URL implementation to get the current file location and to join that with the relative path of the module, and will work in Node.js and in the browser.
Now lets say you have a module that has a default export, there is a slight difference in the way it is imported, namely default exports will be imported under the .default
namespace, so like this.
//foobar.mjs
function foobar() {
return 'foobar';
}export default foobar;
Dynamically imported like this
const currDir = new URL(import.meta.url).pathname
const fooBarLocation = new URL(currDir, '/foobar.mjs')
const foobar = import(fooBarLocation) //full path to foobar.mjs fileconsole.log(foobar.default());
When dynamically importing ES modules with default exports just remember that you reference the default export using module.default
not module
.
It also should be noted, that if you dynamically import ES modules using import()
any static code analysis or tree shaking won’t work on those dynamically imported modules.
Package.json and shipping ES modules?
So what happens with package.json
with ES modules? The agreed specification is that you still use main
to specify your entry point to your ES module. Bundlers like Rollup and Webpack used module
to specify a separate entry point before Node support ES modules.
Now under the --experimental-modules
flag you can specify an .mjs
file under main
in package.json
or don’t specify a file extension at all and Node.js’s resolution algorithm with look for a .mjs
file before a .js
file.
This allows you to do a dual export of CommonJS and ES modules for backwards compatibility with older versions of Node.js, or for developers that don’t won’t to use ES modules just yet.
An example package.json
that exports both CommonJS and ES modules and uses Babel for transpiling would look like this.
{
"name": "dualexport-module",
"version": "1.0.0",
"description": "An example of a module exported with CJS and ESM.",
"license": "MIT",
"main": "index",
"module": "index.mjs",
"engines": {"node": ">= 6.x"},
"scripts": {
"build": "npm run build:cjs && npm run build:mjs",
"build:js": "babel src",
"build:cjs": "npm run build:js -- --env-name cjs --out-dir dist/",
"build:mjs": "npm run build:js -- --env-name mjs --out-dir dist/module && for file in $(find dist/module -name '*.js'); do mv \"$file\" `echo \"$file\" | sed 's/dist\\/module/dist/g; s/.js$/.mjs/g'`; done && rm -rf dist/module", },
"dependencies": {},
"devDependencies": {}
}
In this example main
just specifies index
without an extension. If you execute this package with --experimental-modules
flag it will look for the .mjs
version first, if you execute it without the flag, it will look for the .js
version and ignore the .mjs
version completely.
The package.json
also has 4 build scripts
"build:js"
runs Babel against thesrc
folder"build:cjs"
runs thebuild:js
script setting a build environment variable tocjs
to tell Babel to export CommonJS modules todist/
"build:mjs
runs thebuild:js
script setting a build environment variable to mjs to tell Babel to export ES modules fordist/module
then runs thefind
command to file all.js
files indist/module
and then moves them todist
whilst renaming them from.js
to.mjs
This uses Babel to do the transformation source code written using ES modules, and using the .mjs
extension to a CommonJS and ES module version that can be consumed by either module system.
The .babelrc.js
configuration that goes along with this package.json
looks like this
module.exports = {
presets: [
['@babel/preset-env', { targets: ['node 6'] }],
],
plugins: [
['@babel/plugin-transform-classes', { loose: true }],
['@babel/plugin-transform-destructuring', { loose: true }],
['@babel/plugin-transform-spread', { loose: true }],
['@babel/plugin-syntax-dynamic-import'],
['@babel/plugin-syntax-import-meta']
],
env: {
cjs: {
presets: [
['@babel/preset-env', { modules: 'commonjs' }],
],
},
mjs: {
presets: [
['@babel/preset-env', { modules: false }],
],
},
},
};
The Babel plugins ['@babel/plugin-syntax-dynamic-import']
and ['@babel/plugin-syntax-import-meta']
have been added specifically to handle the new ES module syntax for import.meta.url
and import()
. The problem is that these problems allow parsing of the import.meta.url
and import()
but they don’t transform them, so will bring ES syntax into you CommonJS module, that will fail. The workaround to do this is to handcraft an ES module and CommonJS version of the module using the relevant allowed syntax, and in the CommonJS transpilation config ignore ES module version when transpiling. Extending the above config it would look like this
module.exports = {
presets: [
['@babel/preset-env', { targets: ['node 6'] }],
],
plugins: [
['@babel/plugin-transform-classes', { loose: true }],
['@babel/plugin-transform-destructuring', { loose: true }],
['@babel/plugin-transform-spread', { loose: true }],
['@babel/plugin-syntax-dynamic-import'],
['@babel/plugin-syntax-import-meta']
],
env: {
cjs: {
presets: [
['@babel/preset-env', { modules: 'commonjs' }],
],
ignore: [
'src/functionWithImportMeta.mjs',
'src/functionWithDynamicImport.mjs',
]
},
mjs: {
presets: [
['@babel/preset-env', { modules: false }],
],
},
},
};
This Babel config also assumes that you have written your code using .mjs
modules and the transpile configuration triggered by the esm
environment variable does no transformation of module syntax.
The package.json
keeps the module
as a separate entry point for backwards compatibility reasons, though it’s not strictly needed as Rollup can resolve .mjs
files with the correct configuration of rollup-plugin-node-resolve
This package.json
and .babelrc.js
is based on the configuration used for the graphql
npm module, which from version 14 onwards, is written using ES modules with the .mjs
extension and ships both .js
and .mjs
versions of the module.
The huge advantage this gives anyone using graphql
with a bundler like Rollup is that you can distribute a module with the core GraphQL functionality for a GraphQL server at around 350KB
vs the approximately 2.4MB
of the entire module with both .mjs
and .js
versions.
ES modules for CLI’s
This is very platform dependent, but the general advice is wait for the --experimental-modules
flag to be removed before using ES modules in a CLI.
If you’re using MacOS and you create an executable file in ./bin
and reference it properly in package.json
and use the #!/usr/bin/env node--experimental-modules
shebang for that file, it will work. But it will only work on MacOS, it will not work on any other Linux based operating system. Pain has taught me this.
The reason is that on Linux systems it will only take one parameter in the shebang, the bit #/usr/bin/env
in it, and it won’t read the --experimental-modules
flag as that’s a second parameter to the shebang, as node
is the first parameter.
So unless you’re shipping a CLI for MacOS only, make sure you transpile down to CommonJS code.
Useful links to read
- ECMAScript modules in Node.js: the new plan
- ES Modules, a cartoon deep-dive
- An update on ES6 modules in Node.js
- Node.js ESM documentation
- Node.js URL documentation
- MDN ES Module Import documentation
- MDN ES Module Export documentation
Hopefully you find this useful, and are saved from some of the pain I went through! Getting on top of ES modules now will give you a head start before the --experimental-modules
flag is removed, and help with implementation of the specification as potential bugs can be found before the flag is removed.
Ant Stanley