ponyfoo.com

ES6 Modules in Depth

Fix

Welcome back to ES6 – “Oh, good. It’s not another article about Unicode” – in Depth series. If you’ve never been around here before, start with A Brief History of ES6 Tooling. Then, make your way through destructuring, template literals, arrow functions, the spread operator and rest parameters, improvements coming to object literals, the new classes sugar on top of prototypes, let, const, and the “Temporal Dead Zone”, iterators, generators, Symbols, Maps, WeakMaps, Sets, and WeakSets, proxies, proxy traps, more proxy traps, reflection, Number, Math, Array, Object, and String. This morning is about the module system in ES6.

Like I did in previous articles on the series, I would love to point out that you should probably set up Babel and follow along the examples with either a REPL or the babel-node CLI and a file. That’ll make it so much easier for you to internalize the concepts discussed in the series. If you aren’t the “install things on my computer” kind of human, you might prefer to hop on CodePen and then click on the gear icon for JavaScript – they have a Babel preprocessor which makes trying out ES6 a breeze. Another alternative that’s also quite useful is to use Babel’s online REPL – it’ll show you compiled ES5 code to the right of your ES6 code for quick comparison.

Before getting into it, let me shamelessly ask for your support if you’re enjoying my ES6 in Depth series. Your contributions will go towards helping me keep up with the schedule, server bills, keeping me fed, and maintaining Pony Foo as a veritable source of JavaScript goodies.

Thanks for reading that, and let’s dive deep into the ES6 module system.

The ES6 Module System

Before ES6 we really went out of our ways to obtain modules in JavaScript. Systems like RequireJS, Angular’s dependency injection mechanism, and CommonJS have been catering to our modular needs for a long time now – alongside with helpful tools such as Browserify and Webpack. Still, the year is 2015 and a standard module system was long overdue. As we’ll see in a minute, you’ll quickly notice that ES6 modules have been heavily influenced by CommonJS. We’ll look at export and import statements, and see how ES6 modules are compatible with CommonJS, as we’ll go over throughout this article.

Today we are going to cover a few areas of the ES6 module system.

Strict Mode

In the ES6 module system, strict mode is turned on by default. In case you don’t know what strict mode is, it’s just a stricter version of the language that disallows lots of bad parts of the language. It enables compilers to perform better by disallowing non-sensical behavior in user code, too. The following is a summary extracted from changes documented in the strict mode article on MDN.

  • Variables can’t be left undeclared
  • Function parameters must have unique names (or are considered syntax errors)
  • with is forbidden
  • Errors are thrown on assignment to read-only properties
  • Octal numbers like 00840 are syntax errors
  • Attempts to delete undeletable properties throw an error
  • delete prop is a syntax error, instead of assuming delete global[prop]
  • eval doesn’t introduce new variables into its surrounding scope
  • eval and arguments can’t be bound or assigned to
  • arguments doesn’t magically track changes to method parameters
  • arguments.callee throws a TypeError, no longer supported
  • arguments.caller throws a TypeError, no longer supported
  • Context passed as this in method invocations is not “boxed” (forced) into becoming an Object
  • No longer able to use fn.caller and fn.arguments to access the JavaScript stack
  • Reserved words (e.g protected, static, interface, etc) cannot be bound

In case it isn’t immediately obvious – you should 'use strict' in all the places. Even though it’s becoming de-facto in ES6, it’s still a good practice to use 'use strict' everywhere in ES6. I’ve been doing it for a long time and never looked back!

Let’s now get into export, our first ES6 modules keyword of the day!

export

In CommonJS, you export values by exposing them on module.exports. As seen in the snippet below, you could expose anything from a value type to an object, an array, or a function.

module.exports = 1
module.exports = NaN
module.exports = 'foo'
module.exports = { foo: 'bar' }
module.exports = ['foo', 'bar']
module.exports = function foo () {}

ES6 modules are files that export an API – just like CommonJS modules. Declarations in ES6 modules are scoped to that module, just like with CommonJS. That means that any variables declared inside a module aren’t available to other modules unless they’re explicitly exported as part of the module’s API (and then imported in the module that wants to access them).

Exporting a Default Binding

You can mimic the CommonJS code we just saw by changing module.exports = into export default.

export default 1
export default NaN
export default 'foo'
export default { foo: 'bar' }
export default ['foo', 'bar']
export default function foo () {}

Contrary to CommonJS, export statements can only be placed at the top level in ES6 modules – even if the method they’re in would be immediately invoked when loading the module. Presumably, this limitation exists to make it easier for compilers to interpret ES6 modules, but it’s also a good limitation to have as there aren’t that many good reasons to dynamically define and expose an API based on method calls.

function foo () {
  export default 'bar' // SyntaxError
}
foo()

There isn’t just export default, you can also use named exports.

Named Exports

In CommonJS you don’t even have to assign an object to module.exports first. You could just tack properties onto it. It’s still a single binding being exported – whatever properties the module.exports object ends up holding.

module.exports.foo = 'bar'
module.exports.baz = 'ponyfoo'

We can replicate the above in ES6 modules by using the named exports syntax. Instead of assigning to module.exports like with CommonJS, in ES6 you can declare bindings you want to export. Note that the code below cannot be refactored to extract the variable declarations into standalone statements and then just export foo, as that’d be a syntax error. Here again, we see how ES6 modules favor static analysis by being rigid in how the declarative module system API works.

export var foo = 'bar'
export var baz = 'ponyfoo'

It’s important to keep in mind that we are exporting bindings.

Bindings, Not Values

An important point to make is that ES6 modules export bindings, not values or references. That means that a foo variable you export would be bound into the foo variable on the module, and its value would be subject to changes made to foo. I’d advise against changing the public interface of a module after it has initially loaded, though.

If you had an ./a module like the one found below, the foo export would be bound to 'bar' for 500ms and then change into 'baz'.

export var foo = 'bar'
setTimeout(() => foo = 'baz', 500)

Besides a “default” binding and individual bindings, you could also export lists of bindings.

Exporting Lists

As seen in the snippet below, ES6 modules let you export lists of named top-level members.

var foo = 'ponyfoo'
var bar = 'baz'
export { foo, bar }

If you’d like to export something with a different name, you can use the export { foo as bar } syntax, as shown below.

export { foo as ponyfoo }

You could also specify as default when using the named member list export declaration flavor. The code below is the same as doing export default foo and export bar afterwards – but in a single statement.

export { foo as default, bar }

There’s many benefits to using only export default, and only at the bottom of your module files.

Best Practices and export

Having the ability to define named exports, exporting a list with aliases and whatnot, and also exposing a a “default” export will mostly introduce confusion, and for the most part I’d encourage you to use export default – and to do that at the end of your module files. You could just call your API object api or name it after the module itself.

var api = {
  foo: 'bar',
  baz: 'ponyfoo'
}
export default api

One, the exported interface of a module becomes immediately obvious. Instead of having to crawl around the module and put the pieces together to figure out the API, you just scroll to the end. Having a clearly defined place where your API is exported also makes it easier to reason about the methods and properties your modules export.

Two, you don’t introduce confusion as to whether export default or a named export – or a list of named exports (or a list of named exports with aliases…) – should be used in any given module. There’s a guideline now – just use export default everywhere and be done with it.

Three, consistency. In the CommonJS world it is usual for us to export a single method from a module, and that’s it. Doing so with named exports is impossible as you’d effectively be exposing an object with the method in it, unless you were using the as default decorator in the export list flavor. The export default approach is more versatile because it allows you to export just one thing.

Four, – and this is really a reduction of points made earlier – the export default statement at the bottom of a module makes it immediately obvious what the exported API is, what its methods are, and generally easy for the module’s consumer to import its API. When paired with the convention of always using export default and always doing it at the end of your modules, you’ll note using the ES6 module system to be painless.

Now that we’ve covered the export API and its caveats, let’s jump over to import statements.

import

These statements are the counterpart of export, and they can be used to load a module from another one – first and foremost. The way modules are loaded is implementation-specific, and at the moment no browsers implement module loading. This way you can write spec-compliant ES6 code today while smart people figure out how to deal with module loading in browsers. Transpilers like Babel are able to concatenate modules with the aid of a module system like CommonJS. That means import statements in Babel follow mostly the same semantics as require statements in CommonJS.

Let’s take lodash as an example for a minute. The following statement simply loads the Lodash module from our module. It doesn’t create any variables, though. It will execute any code in the top level of the lodash module, though.

import 'lodash'

Before going into importing bindings, let’s also make a note of the fact that import statements, – much like with export – are only allowed in the top level of your module definitions. This can help transpilers implement their module loading capabilities, as well as help other static analysis tools parse your codebase.

Importing Default Exports

In CommonJS you’d import something using a require statement, like so.

var _ = require('lodash')

To import the default exported binding from an ES6 module, you just have to pick a name for it. The syntax is a bit different than declaring a variable because you’re importing a binding, and also to make it easier on static analysis tools.

import _ from 'lodash'

You could also import named exports and alias them.

Importing Named Exports

The syntax here is very similar to the one we just used for default exports, you just add some braces and pick any named exports you want. Note that this syntax is similar to the destructuring assignment syntax, but also bit different.

import {map, reduce} from 'lodash'

Another way in which it differs from destructuring is that you could use aliases to rename imported bindings. You can mix and match aliased and non-aliased named exports as you see fit.

import {cloneDeep as clone, map} from 'lodash'

You can also mix and match named exports and the default export. If you want it inside the brackets you’ll have to use the default name, which you can alias; or you could also just mix the default import side-by-side with the named imports list.

import {default, map} from 'lodash'
import {default as _, map} from 'lodash'
import _, {map} from 'lodash'

Lastly, there’s the import * flavor.

import All The Things

You could also import the namespace object for a module. Instead of importing the named exports or the default value, it imports all the things. Note that the import * syntax must be followed by an alias where all the bindings will be placed. If there was a default export, it’ll be placed in alias.default.

import * as _ from 'lodash'

That’s about it!

Conclusions

Note that you can use ES6 modules today through the Babel compiler while leveraging CommonJS modules. What’s great about that is that you can actually interoperate between CommonJS and ES6 modules. That means that even if you import a module that’s written in CommonJS it’ll actually work.

The ES6 module system looks great, and it’s one of the most important things that had been missing from JavaScript. I’m hoping they come up with a finalized module loading API and browser implementations soon. The many ways you can export or import bindings from a module don’t introduce as much versatility as they do added complexity for little gain, but time will tell whether all the extra API surface is as convenient as it is large.

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments (9)

Francis Kim wrote

Thanks for the in-depth insight into ES6 modules! <3

Owen Densmore wrote

I’m currently using JSPM as my module loader. It also allows dynamic babel transpilation. Seems to be the closest to the semi-proposed loader standard.

BUT … I see a lot of use of webpack and browserify and am wondering if I’m mistaken in use of JSPM. Basically I’m concerned by the divide in module loaders caused by they’re not being included in the es6 standard.

What do you think? Is JSPM more standards oriented, and if it fits into my workflow, its fine? Or are there great advantages in the other contenders that I’m missing … like better integration into es5?

Brian Lai wrote

Thanks for doing the series. They are very helpful. Maybe you also want to mention this in the Named Exports section.

Sean May wrote

This series is making me really very , and it’s great to see you going head-first compared to your first foray into Gulp/6to5.

For posterity, you might consider changing the wording of the import vs destructuring section:

Another way in which it differs from destructuring is that you could use aliases to rename imported bindings.

That’s actually almost exactly what you can , albeit in a different syntax:

class Vector {
  add ({ x:ax, y:aY }, { x:bX, bY }, out = Vector.zero()) {
    const x = aX + bX;
    const y = aY + bY;

    Object.assign(out, { x, y });
  }
}

Of course, you mention this in the first few paragraphs of the destructuring article; I think you meant to suggest that aliasing uses a different syntax than destructuring. Sadly, that’s not how it reads.

I know this is nit-picking; I just really also want to point dozens of people at these articles (and Axel’s book), because they’re exactly what we need.

Henrique Silvério wrote

Great article Nicolas! ES6 modules is one of my favorites between all new features. I agree with your best practices tips on using exports. Thanks.

Igor Santana wrote

Great article! I just have a question… Do you know whats the difference between

module.exports 

and

exports.foo = 'bar';

? Is there any significant difference?

Nicolas Bevacqua wrote

You should probably read the Node.js documentation on Modules

Ian VanSchooten wrote

In your Best Practices section, you suggest exporting an object as default. But, as far as I can tell, this is an anti-pattern, and only worked because of the way that Babel was transpiling (which has changed in Babel 6). See this StackOverflow post. Can you please confirm or correct my understanding?

Valentin Cocaud wrote

I’m trying to use new ES6 import this way :

import "./utils.js";

log("something");

With the file utils.js like this :

function log(...prams) {
   console.log(...params);
}

But I get an undefined error on calling log(...). I don’t really understand the behavior of this way to use import.

Do you know more about it ?