Module specifiers: what’s new with ES modules?

[2017-05-08] dev, javascript, esnext, jsmodules
(Ad, please don’t block)

This blog post describes how module specifiers (the path-like IDs of modules) change with ECMAScript modules (ESM). There are a few subtle differences, compared to the familiar CommonJS module (CJS) style.

Why a new filename extension for ES modules?  

Node.js will support ES modules via the new filename extension .mjs. Many web developers would have preferred to stick with .js. Let me make the case why .mjs is the better approach.

The core thing to keep in mind is: In order to distinguish between CommonJS modules and ES modules, you need some kind of metadata. Browsers face a similar problem, having to distinguish between scripts and modules. They attach the metadata via the attribute type="module" in <script> elements.

Let’s start with approaches that would enable us to keep .js.

Approaches for keeping .js  

  1. Specify which files are ESM via package.json. One interesting aspect of this approach is that its file system layout is different from all other approaches: ESM versions of CJS files are kept in a separate directory whose structure mirrors the structure of the CJS directory.
    • Cons: It doesn’t work with stand-alone files. And you have to look elsewhere to find out what a given file is.
  2. Detection via parsing: It is possible to parse a file with a grammar that covers both scripts (incl. CommonJS modules) and ES modules. After parsing, one inspects the resulting abstract syntax tree to find out what kind of file one is dealing with.
    • Con: There are files that could be either CJS or ESM – files that neither import nor export. For example, polyfills that install things into global variables. These must be interpreted differently depending on whether they are CJS or ESM.
    • Con: Additionally, this kind of detection affects performance negatively.
  3. A pragma at the beginning of the file (similar to strict mode, e.g. "use module";). You could combine this approach with detection via parsing and only use a pragma where a file is ambiguous.
    • Cons: The precedent for pragmas is strict mode. With strict mode, I know that I should use it, but I’m usually too lazy to do so and I don’t like the clutter it adds to my files. Therefore, my conclusion is that the user experience of pragmas is bad. A pragma is something we would use now to distinguish between CJS and ESM. But CJS would disappear in the long run, leaving us with meaningless clutter in legacy files (and possibly current files, too). I’d be OK with pragmas for CJS (i.e., legacy files), but that probably wouldn’t be useful.

One issue with approaches #2 and #3 is that you can’t keep an ESM and a CJS version of a file next to each other (unless you also use approach #1).

It feels wrong to me that either human or machine should have to put that much effort into finding out what kind of file they are dealing with.

The filename extension .mjs  

Using a different filename extension is a simple way of attaching metadata to files.

There are many precedents for putting slightly different JavaScript into files with different filename extensions. For example:

  • *.jsx for React code with JSX
  • *.ts for TypeScript files
  • *.es6 for files to be compiled via Babel (initially popular, now getting out of fashion)

Why is keeping .js desirable?  

Whatever filename extension for modules we’ll end up with, it will be the dominant extension for JavaScript in the future. Thus, moving away from .js is visually undesirable.

Another argument in favor of keeping .js is that tools need to be adapted in order to recognize .mjs files as containing JavaScript. However, I’d argue that most tools need to be changed significantly to meaningfully handle ESM, anyway.

Looking at the pros and cons of all approaches, using .mjs is not the best solution, it’s the least bad solution.

Module specifiers  

Module specifiers are path-like identifiers for modules. For example:

import lodash from 'lodash';

The string 'lodash' is a module specifier.

How do module specifiers change with ESM?  

The key change with ES modules is:

All ES module specifiers must be valid URLs

In particular, if the filename of a module has an extension then its specifier must have one, too. That is exactly like non-module <script> elements work today.

The following import statements all have legal module specifiers:

import * as foo from 'http://example.com/lib/foo.mjs';
import * as foo from '/lib/foo.mjs';
import * as foo from './foo.mjs';
import * as foo from '../foo.mjs';

For compatibility with CommonJS and in preparation for future features, relative paths that don’t start with ./ or ../ are not allowed:

// Not allowed:
import * as foo from 'foo.mjs';
import * as foo from 'lib/foo.mjs';

Best practices  

These are two best practices for writing module specifiers:

  1. Referring to ES modules stored in local files: include the filename extensions. This applies to both browsers and Node.js.

    • Browsers don’t care about filename extensions, only about MIME types. Given the importance of npm for client-side development, also switching to .mjs in browsers will help with keeping JavaScript code cross-platform compatible.
    • Note that the module specifier './foo.mjs' may (among others) refer to: foo.mjs, foo.mjs/index.mjs, foo.mjs/index.js
  2. Referring to ES modules in node_modules:

    • Node.js: omit the filename extension. The specifier works regardless of whether a module is ESM or CJS.
    • Browsers: it’ll be interesting to see how uncompiled code will handle node_modules (not much will change for code compiled by tools such as webpack, because such tools can resolve paths at compile time). One option is to map node_modules paths to actual paths via a configuration file (similar to how AMD does it). Another option is to link into node_modules directories.

It’s unclear if node_modules specifiers will eventually have filename extensions, too.

Support for .mjs in tools  

Adding support for .mjs to tools is ongoing. For example:

  • Babel: https://github.com/babel/babel/pull/5624
  • Babili/babel-minify already supports .mjs: https://github.com/babel/babili/blob/2b1b16ac05596e65ec77c56a1e3e1b7882991341/packages/babili/src/fs.js#L6
  • AVA: https://github.com/avajs/ava/issues/631#issuecomment-299106074
  • Visual Studio Code: https://github.com/Microsoft/vscode/pull/25747

For Visual Studio Code, I added this to my workspace settings:

"files.associations": {
  "*.mjs": "javascript"
}

Acknowledgement: Thanks to Bradley Farias for answering my module-specifier-related questions.

Further reading