The new ECMAScript module support in Node.js 12

[2019-04-23] dev, javascript, nodejs, jsmodules
(Ad, please don’t block)

Follow-up blog post: “Hybrid npm packages (ESM and CommonJS)”


Node.js 12 (which was released on 2019-04-23) brings improved support for ECMAScript modules. It implements phase 2 of the plan that was released late last year. For now, this support is available behind the usual flag --experimental-modules.

Read on to find out how exactly this new support for ECMAScript modules works.

Brief spoiler: The filename extension .mjs will be more convenient, but .js can also be enabled for ES modules.

Terms and abbreviations used in this blog post  

  • CommonJS module (CJS): refers to the original Node.js module standard.
  • ECMAScript module (ES module, ESM): refers to modules as standardized via the ECMAScript specification.
  • package.prop means property prop of package.json.

Module specifiers  

Module specifiers are the strings that identify modules. They work slightly differently in CommonJS modules and ES modules. Before we look at the differences, we need to learn about the different categories of module specifiers.

Categories of module specifiers  

In ES modules, we distinguish the following categories of specifiers. These categories originated with CommonJS modules.

  • Relative path: starts with a dot. Examples:

    './some/other/module.mjs'
    '../../lib/counter.mjs'
    
  • Absolute path: starts with a slash. Example:

    '/home/jane/file-tools.mjs'
    
  • URL: includes a protocol (technically, paths are URLs, too). Examples:

    'https://example.com/some-module.mjs'
    'file:///home/john/tmp/main.mjs'
    
  • Bare path: does not start with a dot, a slash or a protocol, and consists of a single filename without an extension. Examples:

    'lodash'
    'the-package'
    
  • Deep import path: starts with a bare path and has at least one slash. Example:

    'the-package/dist/the-module.mjs'
    

CommonJS module specifiers  

This is how CommonJS handles module specifiers:

  • CommonJS does not support URLs as specifiers.
  • Relative paths and absolute paths are handled as expected.
  • You can load a directory foo as a module:
    • If there is a file foo/index.js
    • If there is a file foo/package.json whose property "main" points to a module file.
  • Bare paths and deep import paths are resolved against a directory node_modules that is found:
    • In the same directory as the importing module
    • In the parent of that directory
    • Etc.
  • If a specifier X does not refer to a file, the system tries the specifiers X.js, X.json and X.node.

Additionally, CommonJS modules have access to two special module-global variables:

  • __filename: contains the path of the current module.
  • __dirname: contains the path of the parent directory of the current module.

Source of this section: page “Modules” of the Node.js documentation.

ES module specifiers in Node.js  

  • All specifiers, except bare paths, must refer to actual files. In contrast to CommonJS, ESM does not add missing filename extensions.
  • Only file: is supported as a protocol for URL specifiers.
  • Absolute paths are currently not supported. As a work-around, you can use a URL that starts with file:///.
  • Relative paths are resolved as they are in web browsers – relative to the path of the current module.
  • Bare paths are resolved relative to a node_modules directory. The module that a bare path refers to, is specified via package.main (similarly to CJS).
  • Deep import paths are also resolved relative to a node_modules directory.
  • Importing directories is not supported. In other words, neither package.main (which can only be used for packages) nor index.* work.

All built-in Node.js modules are available via bare paths and have named ESM exports. For example:

import * as path from 'path';
import * as assert from 'assert';

assert.equal(
  path.join('a/b/c', '../d'), 'a/b/d');

Filename extensions  

Node.js supports the following default filename extensions:

  • .mjs for ES modules
  • .cjs for CommonJS modules

The filename extension .js stands for either ESM or CommonJS. Which one it is, depends on package.type, which has two settings:

  • commonjs (the default): files with the extension .js or without an extension are parsed as CommonJS.

    "type": "commonjs"
    
  • module: files with the extension .js or without an extension are parsed as ESM.

    "type": "module"
    

To find the package.json for a given file, Node.js searches in the same directory as the file, the parent directory, etc.

Interpreting non-file source code as either CommonJS or ESM  

Not all source code that is executed by Node.js, comes from files. You can also send it code via stdin, --eval and --print. The command line option --input-type lets you specify how such code is interpreted:

  • As CommonJS (the default): --input-type=commonjs
  • As ESM: --input-type=module

Interoperability  

Importing CommonJS from ESM  

At the moment, you have two options for importing a CommonJS module from an ES module.

Consider the following CommonJS module.

// common.cjs
module.exports = {
  foo: 123,
};

The first option is to default-import it (support for named imports may be added in the future):

// es1.mjs
import * as assert from 'assert';

import common from './common.cjs'; // default import
assert.equal(common.foo, 123);

The second option is to use createRequire():

// es2.mjs
import * as assert from 'assert';

import {createRequire} from 'module';
const require = createRequire(import.meta.url);

const common = require('./common.cjs');
assert.equal(common.foo, 123);

Importing ESM from CommonJS  

If you want to import an ES module from a CommonJS module, you can use the import() operator.

As an example, take the following ES module:

// es.mjs
export const bar = 'abc';

Here we import it from a CommonJS module:

// common.cjs
const assert = require('assert');

async function main() {
  const es = await import('./es.mjs');
  assert.equal(es.bar, 'abc');
}
main();

Various other features  

import.meta.url  

Given that __filename and __dirname are not available in ES modules, we need an alternative. import.meta.url is that alternative. It contains a file: URL with an absolute path. For example:

'file:///Users/rauschma/my-module.mjs'

Important: Use url.fileURLToPath() to extract the path – new URL().pathname doesn’t always work properly:

import * as assert from 'assert';
import {fileURLToPath} from 'url';

//::::: Unix :::::

const urlStr1 = 'file:///tmp/with%20space.txt';
assert.equal(
  new URL(urlStr1).pathname, '/tmp/with%20space.txt');
assert.equal(
  fileURLToPath(urlStr1), '/tmp/with space.txt');

const urlStr2 = 'file:///home/thor/Mj%C3%B6lnir.txt';
assert.equal(
  new URL(urlStr2).pathname, '/home/thor/Mj%C3%B6lnir.txt');
assert.equal(
  fileURLToPath(urlStr2), '/home/thor/Mjölnir.txt');

//::::: Windows :::::

const urlStr3 = 'file:///C:/dir/';
assert.equal(
  new URL(urlStr3).pathname, '/C:/dir/');
assert.equal(
  fileURLToPath(urlStr3), 'C:\\dir\\');

The inverse of url.fileURLToPath() is url.pathToFileURL(): it converts a path to a file URL.

We’ll see an example of using import.meta.url and url.fileURLToPath() later in this post.

fs.promises  

fs.promises contains a promisified version of the fs API and works as expected:

import {fileURLToPath} from 'url';
import {promises as fs} from 'fs';

async function main() {
  // The path of the current module
  const pathname = fileURLToPath(import.meta.url);
  const str = await fs.readFile(pathname, {encoding: 'UTF-8'});
  console.log(str);
}
main();

--experimental-json-modules  

With the flag --experimental-json-modules, Node.js loads .json files as JSON.

Take, for example, the JSON file data.json:

{
  "first": "Jane",
  "last": "Doe"
}

We can import it from an ES module as follows (if we use both the flag for ESM and for JSON modules):

import * as assert from 'assert';
import data from './data.json';

assert.deepEqual(
  data,
  {first: "Jane", last: "Doe"});

ES modules on npm  

At the moment, with a bare path 'mylib', you have to decide between:

  • require('mylib')
  • import from 'mylib'

You can’t do both (deep import paths are a reasonable work-around). An effort to change that is ongoing. It will probably be done by making package.main more powerful.

Until that feature is ready, the people working on it, have the following request:

“Please do not publish any ES module packages intended for use by Node.js until this is resolved.”

Using ES modules on Node.js  

Starting with Node.js 12, you have the following options for using ES modules on Node.js:

The flag for ESM support will probably be removed in October 2019, when Node.js 12 reaches LTS status.

Further reading and sources of this blog post  

Acknowledgements – thanks for reviewing this blog post: