How do ECMAScript modules work in Node.js?

Traditionally, Node.js uses the “CommonJS” module system, which I described recently. But since 2015, the JavaScript world has had ECMAScript modules. Node.js now supports ECMAScript modules as well as CommonJS modules. They’re inter-operable, too, but this can make things pretty complex. Let’s take a look.

When node first runs, you give it a module to run, e.g. with node file.js, or node .. Node must decide whether that module is ECMAScript or CommonJS. You might decide by eyeballing the file contents, e.g. seeing whether it has import annotations or require calls. But Node.js does not look at the contents, or execute them, to make this decision. First, it looks at the file extension. The extension .mjs signals ECMAScript; the extension .cjs signals CommonJS. If the extension is just .js, it will look for a package.json in the path from the root to the file, and check the type field, which can be "module" (ECMAScript) or "commonjs". Otherwise, it guesses that the module is CommonJS, and we’ll get runtime SyntaxErrors if this guess is wrong.

The Node REPL is in CommonJS mode. Like the console in the browser, you can’t use static imports here. Because of this, I fall back into the habit of using CommonJS. However, you can use the dynamic import(...) call! To make this useable, start the REPL with --experimental-repl-await, so you can write things like const { readFileSync } = await import('fs').

Node’s require behavior is pretty complex. For example, a module at node_modules/express/lib/express.js might have a call to require('body-parser'). At runtime, this might resolve to the file at node_modules/body-parser/index.js. This happens by crawling the filesystem and package.json files to find a module that matches the string. ECMAScript import in the browser is much more restricted: you can only specify a relative URL like import * as m from './myModule.js', or an absolute URL like import * as $ from 'https://example.com/jquery.js'. But Node’s ECMAScript module system has the same complex resolution algorithm as its require system. For example, an ECMAScript module at node_modules/express/lib/express.mjs can have a call to import * as bodyParser from 'body-parser', which also resolves to the file at node_modules/body-parser/index.js.

(The “import maps” proposal would provide a resolution algorithm for ECMAScript modules in the browser that allows you to write things like import * as $ from 'jquery' and have this resolve to e.g. https://example.com/node_modules/jquery/index.js. But this browser feature is not really implemented or available yet.)

From an ECMAScript module, you can only use import, not require. And from a CommonJS module, you can only use require, not import. Trying to mix the two forms in the same file will give you errors.

If you require(foo), but foo resolves to an ECMAScript module, you’ll get an error. And if you import foo but foo resolves to a CommonJS module ... what do you think happens? Nope, it’s not an error. Actually, the CommonJS module is executed, and its exports object is used as the default export. This is how the old CommonJS ecosystem is made available to the new ECMAScript ecosystem!

So, you can write import foo from './foo.cjs', which is roughly equivalent to const foo = require('./foo.cjs'). If you’re used to writing const {x,y,z} = require('./foo.cjs') in CommonJS, you might try writing import {x,y,z} from './foo.cjs' in ECMAScript modules. But this doesn’t work: the module ./foo.cjs can’t have x,y,z exports; it can only have a default export!

To make things more complex, a Node.js module can be both a CommonJS module and an ECMAScript module. The exports field of a package.json can explicitly declare things like:

{
    "exports": {
        "import": "./index.mjs",
        "require": "./index.cjs"
    }
}

The Node.js resolution algorithm knows whether it’s requireing or importing. If it’s requireing, it will pick ./index.cjs. If it’s importing, it will pick ./index.mjs. This means a single npm package can provide for both module systems.

Note that in some packages you might see a different format in the package.json, like:

{
    "main": "./index.cjs",
    "module": "./index.mjs"
}

But this module field is ignored by Node.js. Node.js only respects the main field, and so will always run this module as CommonJS. The module field is only read by some bundling tools, like rollup.

Tagged #programming, #javascript.

Similar posts

More by Jim

Want to build a fantastic product using LLMs? I work at Granola where we're building the future IDE for knowledge work. Come and work with us! Read more or get in touch!

This page copyright James Fisher 2020. Content is not associated with my employer. Found an error? Edit this page.