Tom MacWright

tom@macwright.com

ES6 Modules aren't just syntax sugar

Sidenote protip: the thing that made this blog post writable for me, and a thing that I do and would recommend you try doing, is reading machine-readable output. Compiled, compressed, so-called-unreadable output, is, if you look at long enough, still readable. No superpowers are required: if you can get through difficult literature, you can consume code. But doing so requires a change in attitude: whether you want to treat minified JavaScript as opaque, or crack it open. Or if you want to treat SVGs as only the output of Sketch, or to try reading them. I highly recommend giving it a shot.

Sidenote disclaimer: this post isn’t a recommendation to use ES6 modules, or to stay away from them. Some of my projects do, others don’t. I think that the benefits outweigh the negatives in some cases, and in other cases they don’t. I love the fact that they’re explicit and efficient, but fear their slow acceptance.o Will ES6 modules be seen as a maturation of JavaScript, or a mistake that fragments the language and makes modularization harder? The truth is, I don’t know.

Sidenote credit: like most core JavaScript topics, Axel Rauschmayer already wrote an astonishingly precise, fantastic, in-depth explanation of the new module system. Much credit to him: this will just discuss oft-misunderstood topics, and hopefully emphasize just the cool parts.

But to the point, let’s dive into how ES6 modules are different.

Defining bindings and scopes

What’s a binding?

var a = 1;

Has three parts:

  1. a, an Identifier. Identifiers are names that can be assigned to values in JavaScript.
  2. 2, avalue` - in this case, a NumericLiteral.
  3. var a = 2, a binding of the value 2 to the name a, with the var type, which means that a can be reassigned and has function scope. It could also be a let or const binding. The binding is what allows us to refer to the value 2, or whatever the value of a is, by writing a, instead of writing the literal value.

What’s a scope?

function add(a, b) {
  // bind j to 10, a variable binding within the scope
  // of add
  var j = 10;
  // a, b are in scope
  return a + b;
}

add(1, 2);

// This line will fail: j is in scope of add, and it
// isn't accessible outside of add.
console.log(j);

I said ‘scope’ when I described this binding. What’s a scope? A scope is a set of bindings that are available within some section of code. That section might be a function, a module, or a block, like the if part of an if else statement.

Scopes can be hard to describe and hard to explain because nothing says ‘scope’ in JavaScript: they’re implied by the structure. Mainly when we talk about scope we refer to things in scope, like variables defined inside of functions and the parameters of functions, and things that are out of scope, like variables defined outside of a function or in a different module.

What Node modules do

Let me explain this by starting with Node.js modules.1

My mental model of this type of module was:

  1. Each module is the body of a closure that runs and adds properties to a module.exports object.
  2. require() grabs the modules.export object filled by another module and brings it elsewhere.

This model pretty much describes how typical Node modules work, and you can confirm this by using browserify. If you take this module:

module.exports.a = 1;

And run it through browserify, you get this code, which I’ve simplified and formatted for presentation.

(function outer (modules, cache, entry) {

  function require(name) {
    if (!cache[name]) {
      var m = cache[name] = {
        exports: {}
      };

      // This is where the action is. Modules get wrapped
      // in the body of functions when they're bundled by browserify,
      // and those functions are called with module.exports
      // in scope. You get to assign values to the module.exports
      // object, and then we use that object as the module's exports
      modules[name][0].call(m.exports, function(x) {
          var id = modules[name][1][x];
          return require(id ? id : x);
      }, m, m.exports, outer, modules, cache, entry);
    }
    return cache[name].exports;
  }

  for (var i = 0; i < entry.length; i++) {
      require(entry[i]);
  }

  return require;
})({
    1: [
      function(require, module, exports) {
        module.exports.a = 1;
      },
      {}
    ]
  }, {}, [1]
);

I’m not sure if that code example will be explanatory to everyone, so here’s another way to look at it.

We said before that scopes were sets of bindings that were accessible within a certain chunk of code. Modules have scope, so bindings that you make inside of modules can never make their way out. This is good, in a way - you never accidentally define a in one module and a in another and the two conflict: you can be sure that internal variables don’t conflict.

What you do push out of Node modules are values that you attach to module.exports as properties. And then in other modules, you can get those values out and add them to other bindings.

Where this is really obvious is the different kinds of variable bindings. var has been around for a while and is very popular, but let and const, types of bindings that give you more control, are increasingly common in ES6 code.

What ES6 modules do

ES6 modules export and import bindings, not values.

We’ll use the same trick as we did with Node modules: using a bundler to show how these new kinds of modules work. But instead of Browserify, we’ll use Rollup, a new bundler that has great, strict support for ES6 modules.

So, we bundle two files:

a.js

export const a = 1;

add.js

import { a } from './a';
console.log(a + 1);

Running rollup on add.js creates this combined file:

const a = 1;
console.log(a + 1);

Note, first, that this is a lot shorter than even the browserify output for one file. The efficiency of ES6 for bundling is one of its biggest selling points.

And then notice that the code in a.js and the code in add.js live in the same scope, so when we’re referring to a in add.js, we aren’t referring to a value of 1 that we required from one module to another, but the binding of a, of the const type, that is magically transported from a.js into add.js.

You can see this for yourself if you try to reassign a:

import { a } from './a';

console.log(a++);

Produces:

🚨   Illegal reassignment to import 'a'
es6.add.js (3:12)
1: import { a } from './a';
2:
3: console.log(a++);

And the module we require has control over this binding: import { a } from './a' doesn’t specify whether a is a var or a let or a const variable. `

But wait, you might be thinking: modules have scopes, so they have that nice guarantee that internal variables you define in modules never conflict! Well, luckily ES6 modules aren’t really all in the same scope, they’re in scopes that share bindings. If you have some internal variable with a name conflict, Rollup helpfully, transparently, renames it for you in the generated output:

add.js

import { a } from './a';
var x = 10;
console.log(a + 10);

a.js

var x = 10;
export const a = x;

Generates:

var x$1 = 10;
const a = x$1;

console.log(a + 10);

An example of a place where ES6 modules are really interesting

String constants are strings, like "SAVED", that let programs communicate about types or actions simply. Redux, especially, uses string constants to differentiate between different actions: in one part of the application, you send an action, like { type: 'USER_SAVED' } and in another you test for what thing action.type refers to. So it’s very important, when you’re using string constants, that nobody mistyles USER_SAVE instead.

As we discussed, Node modules share values not bindings: if you have a file like constants.js with the content:

module.exports.userSaved = 'USER_SAVED';

Another module could accidentally assign the value ‘USER_SAVED’ to a variable with a var binding and modify it:

var userSaved = require('./constants').userSaved;

// Whoops, we reassign USER_SAVED here by using = instead
// of ==. This will be a problem!
if (userSaved = 'USER_SAVE') {
  // stuff
}

In stark contrast, if you were using ES6 modules and had exported the binding of userSaved of the type const, this example wouldn’t compile:

import { userSaved } from './constants';

// Error here: we're reassigning a const!
if (userSaved = 'USER_SAVE') {
  // stuff
}

The three kinds of { a }

I’d love to clear up one other common point of confusion while I’m at it.

JavaScript’s syntax has become fancier, and has recently acquired three significantly different places where the same code, { a }, in different contexts, means significantly different things.

I call two of these syntax sugar. Syntax sugar is: syntax, or ways to write code, that are perfectly equivalent to some other code, usually longer code, but are shorter and more convenient. Syntax sugar is useful and convenient, but importantly doesn’t introduce any big new concepts: it’s just shortcuts.

  1. Import specifiers (ImportSpecfier)
  2. Destructuring assignment (ObjectPattern) - syntax sugar
  3. Object property shorthand (ObjectProperty) - syntax sugar

So:

Import specifiers:

import { a } from 'a.js';

This is the way you import named exports from a module into another module. The bindings you import will have whatever binding - let, var, or const - that they have in a.js.

Destructuring assignment:

var anObject = { a: 2 };
var { a } = anObject;

This grabs the value of a out of anObject and binds it to the variable a with a var binding.

People also often use destructuring assignment to get individual properties from Node modules.

var { a }  = require('some-module');

Though this is in a really similar context to import, it’s doing a totally different thing: it’s taking the a property from some-module’s exports and binding it to a with a var binding.

It is perfectly equivalent to:

var someModule  = require('some-module');
var a = someModule.a;

Meaning: it is purely syntax sugar.

Object property shorthand:

var a = 2;
var anObject = { a };

This is, like destructuring assignment, purely syntax sugar: it’s exactly equivalent to

var a = 2;
var anObject = { a: a };

Just, shorter.

Fin

As I said, there are advantages and disadvantages to every technology choice: you might sacrifice simplicity for power, or make a bet on the future and it might work out and might not.

I hope that this at least it clear that ES6 modules aren’t just syntax sugar: they’re conceptually new in a way that you can’t easily replicate in Node modules. That, in fact, is one of the reasons why they’ve been slow to gain acceptance, but might also be a reason why they represent real change in how modularity in JavaScript works.

  1. Node.js modules are often called CommonJS modules, but they aren’t technically just CommonJS modules. CommonJS was a wide-ranging specification that tried to make lots of server-side JavaScript APIs uniform, and the module system was the only part that Node.js truly adopted, and after it did, they expanded the module system. So Node.js modules are kind of “CommonJS plus” modules.