What do ES6 modules export?

[2015-07-23] esnext, dev, javascript
(Ad, please don’t block)

CommonJS modules export values, while ES6 modules export immutable bindings. This blog post explains what that means.

You should be loosely familiar with ES6 modules. If you aren’t, you can consult the chapter on modules in “Exploring ES6”.

CommonJS modules export values  

With CommonJS (Node.js) modules, things work in relatively familiar ways.

If you import a value into a variable, the value is copied twice: once when it is exported (line A) and once it is imported (line B).

//------ lib.js ------
var mutableValue = 3;
function incMutableValue() {
    mutableValue++;
}
module.exports = {
    mutableValue: mutableValue, // (A)
    incMutableValue: incMutableValue,
};

//------ main1.js ------
var mutableValue = require('./lib').mutableValue; // (B)
var incMutableValue = require('./lib').incMutableValue;

// The imported value is a (disconnected) copy of a copy
console.log(mutableValue); // 3
incMutableValue();
console.log(mutableValue); // 3

// The imported value can be changed
mutableValue++;
console.log(mutableValue); // 4

If you access the value via the exports object, it is still copied once, on export:

//------ main2.js ------
var lib = require('./lib');

// The imported value is a (disconnected) copy
console.log(lib.mutableValue); // 3
lib.incMutableValue();
console.log(lib.mutableValue); // 3

// The imported value can be changed
lib.mutableValue++;
console.log(lib.mutableValue); // 4

ES6 modules export immutable bindings  

In contrast to CommonJS modules, ES6 modules export bindings, live connections to values. The following code demonstrates how that works:

//------ lib.js ------
export let mutableValue = 3;
export function incMutableValue() {
    mutableValue++;
}

//------ main1.js ------
import { mutableValue, incMutableValue } from './lib';

// The imported value is live
console.log(mutableValue); // 3
incMutableValue();
console.log(mutableValue); // 4

// The imported value can’t be changed
mutableValue++; // TypeError

If you import the module object via the asterisk (*), you get similar results:

//------ main2.js ------
import * as lib from './lib';

// The imported value is live
console.log(lib.mutableValue); // 3
lib.incMutableValue();
console.log(lib.mutableValue); // 4

// The imported value can’t be changed
lib.mutableValue++; // TypeError

Why export bindings?  

Given that exporting bindings is different from how data is normally transported in JavaScript – why do it this way? It has the benefit of making it easier to deal with cyclic dependencies. The following code is an example of a cyclic dependency:

//------ a.js ------
import {bar} from 'b';
export function foo() {
    bar();
}

//------ b.js ------
import {foo} from 'a';
export function bar() {
    if (Math.random()) foo();
}

a.js imports bar from b.js, which means that b.js is executed before a.js. But how can b.js access foo then, if a.js hasn’t provided a value for it, yet? b.js imports a binding, which initially refers to an empty slot. Once a.js is executed, it fills in that slot. Therefore, b.js only has a problem if it uses foo in the top level of its body, while it is executed. Using foo in entities that are accessed after the evaluation of a.js are fine. One such entity is the function bar().

This may seem like an theoretical exercise, but cyclic dependencies can happen relatively easily in large code bases, especially during refactoring. Cycles tend to be longer (for example: m1 imports m2 imports m3 imports m4 imports m1), but the problem is the same.

Consult the section “Cyclic dependencies in CommonJS” in “Exploring ES6” to find out how cyclic dependencies are handled in CommonJS.

Exporting bindings  

How are bindings handled by JavaScript? Exports are managed via the data structure export entry. All export entries (except those for re-exports) have the following two names:

  • Local name: is the name under which the export is stored inside the module.
  • Export name: is the name that importing modules need to use to access the export.

After you have imported an entity, that entity is always accessed via a pointer that has the two components module and local name. In other words, that pointer refers to a binding inside a module.

Let’s examine the export names and local names created by various kinds of exporting. The following table (adapted from the ES6 spec) gives an overview, subsequent sections have more details.

Statement Local name Export name
export {v}; 'v' 'v'
export {v as x}; 'v' 'x'
export let v = 123; 'v' 'v'
export function f() {} 'f' 'f'
export default function f() {} 'f' 'default'
export default function () {} '*default*' 'default'
export default 123; '*default*' 'default'

Export clause  

function foo() {}
export { foo };
  • Local name: foo
  • Export name: foo
function foo() {}
export { foo as bar };
  • Local name: foo
  • Export name: bar

Inline exports  

This is an inline export:

export function foo() {}

It is equivalent to the following code:

function foo() {}
export { foo };

Therefore, we have the following names:

  • Local name: foo
  • Export name: foo

Default exports  

There are two kinds of default exports:

  • Default exports of hoistable declarations (function declarations, generator declarations) and class declarations are similar to normal inline exports in that named local entities are created and tagged.
  • All other default exports are about exporting the results of expressions.

Default-exporting expressions  

The following code default-exports the result of the expression 123:

export default 123;

It is equivalent to:

const *default* = 123; // *not* legal JavaScript
export { *default* as default };

If you default-export an expression, you get:

  • Local name: *default*
  • Export name: default

The local name was chosen so that is wouldn’t clash with any other local name.

Note that a default export still leads to a binding being exported. But, due to *default* not being a legal identifier, you can’t access that binding from inside the module.

Default-exporting hoistable declarations and class declarations  

The following code default-exports a function declaration:

export default function foo() {}

It is equivalent to:

function foo() {}
export { foo as default };

The names are:

  • Local name: foo
  • Export name: default

That means that you can change the value of the default export from within the module, by assigning a different value to foo.

(Only) for default exports, you can also omit the name of a function declaration:

export default function () {}

That is very similar to default-exporting an expression and therefore equivalent to:

function *default*() {} // *not* legal JavaScript
export { *default* as default };

The names are:

  • Local name: *default*
  • Export name: default

Default-exporting generator declarations and class declarations works similarly to default-exporting function declarations.

Re-exports  

Re-exports are handled differently from normal exports. A re-export does not have a local name, it refers to the re-exported entity via that entity’s module and export name (shown in the column “Import name” below).

Statement Module Import name Export name
export {v} from 'mod'; 'mod' 'v' 'v'
export {v as x} from 'mod'; 'mod' 'v' 'x'
export * from 'mod'; 'mod' '*' null

Exported bindings in the spec  

This section gives pointers into the ECMAScript 2015 (ES6) language specification.

Managing imported bindings:

The export names and local names created by the various kinds of exports are shown in table 42 in the section “Source Text Module Records”. The section “Static Semantics: ExportEntries” has more details. You can see that export entries are set up statically (before evaluating the module), evaluating export statements is described in the section “Runtime Semantics: Evaluation”.

Be careful with ES6 transpilers  

ES6 transpilers compile ES6 modules to ES5. Due to the completely new way of passing on data (via bindings), you should expect the ES5 version to not always be completely compliant with the ES6 spec. Things are even trickier when transpiled ES6 code has to interoperate with native CommonJS or AMD modules.

That being said, Babel hews pretty close to the spec, as you can see in the GitHub repository for this blog post.