The Troubles With JavaScript Modules

Tuesday, October 4, 2016

This post is one in a series of posts where I describe common problems developers face using ES2015 features of JavaScript. In this post we look at modules.

The Syntax Pitfall

The first pitfall developers hit when using modules is making assumptions about the syntax. I fell into this trap myself.

import {Person, Animal} from "./lib"

Curly braces in JavaScript appear everywhere. We use them to define block statements, object literals, and more recently, use them for destructuring. Once I learned about destructuring, I looked at my next import statement and wrongly assumed JavaScript was destructuring a node-like module object into new variables.

That’s all wrong!

Import statements create bindings with behaviors that transcend mere variable declarations.

Immutable Bindings

Consider a module with the following export.

export let counter = 0;

And now a module that wants to consume the export.

import {counter} from "./lib/exporter";

counter = 2;

The code trying to set the counter is in error because import bindings are immutable.

What sort of error will you see?

The specification calls for a TypeError, however:

    - we currently don’t have a runtime environment that uses ES modules natively because the module loading spec is still a work in progress, and

    - we rely on transpilers to transform ES 2015 imports and exports into de-facto standards like CommonJS where the rules are relaxed

For those reasons, the error we will see (or not see) depends on the tools we use. For example, the TypeScript compiler will give an error on any assignment to counter – “Invalid left-hand side of assignment expression”. Babel will give us a similar build-time error. As an aside, this is the type of scenario that worries me. Features like import bindings, variable scopes, const, and others might work differently when we transpile for newer runtimes in the future and use these features natively. I don’t foresee catastrophic problems, but there will be some headaches along the way.

Live Bindings

The behavior of bindings also surprises some developers, particularly when importing state from another module. Let’s add some additional exports to the exporting module.

export let counter = 0;

export let creature = {
    name: "Oscar"
};

export function increment() {
    counter += 1;
    return counter;
}

export function inspect() {
    return creature.name;
}

export function reset() {
    creature = { name: "Oscar" };
}

Although we can’t import and then mutate the value of the counter binding, we can call a piece of code in the exporting module that can change the value of the counter.

import {counter, increment} from "./lib/exporter";

describe("binding behavior", () => {
    it("is live", () => {

        expect(counter).toBe(0);

        increment();

        expect(counter).toBe(1);

    });
});

Notice the change to counter is visible inside the importing module. The same behavior holds for objects, too.

import {creature, inspect, reset} from "./lib/exporter";

describe("binding behavior", () => {
    it("is live", () => {

        expect(creature.name).toBe("Oscar");

        // this is legal - not trying to change the binding
        creature.name = "Scott";

        // everyone sees the change, even the exporting module
        expect(inspect()).toBe("Scott");

        // but only the exporter can change the binding value
        reset();
        expect(creature.name).toBe("Oscar");
    })
});

For developers, it’s important to understand that modules are singletons. Any module importing counter and creature will see the same values.

Static Semantics

Node developers accustomed to the flexibility of ConmmonJS can be disappointed by the inflexible, concrete nature of ES 2015 modules. The ES specification gives tools and runtimes the ability to statically analyze module code to discover imports and exports. Static analysis is good for early error detection, bundlers, optimizers, and tools in general, but not so good for anyone who wants to dynamically load modules. Dynamic loading is not out of the question, however. Dynamic loading will be something you can do with the module loading API at runtime (System.import, for example), but not with the ES syntax itself.

Which isn’t too say there is no flexibility in ES modules. RxJS 5 has an interesting design. The following import statement brings in large swaths of the library so you do not need to explicitly add individual operators.

import {Observable} from "rxjs";

If you want to build  a smaller application bundle, you can import Observable from a different location and add just the operators you need. 

import {Observable} from "rxjs/Observable";
import "rxjs/add/operator/map";

Default Exports versus Named Exports

Finally, another area of confusion exists when working with libraries like React that provide both named and default exports. To grab a default export, the code doesn’t use curly braces.

import React, {Component} from "react";

I’ve seen a few developers try to use the braceless syntax to grab named exports, but without braces you can only grab the default export.

Previous Topics

The Troubles with JavaScript Classes

The Troubles with JavaScript Arrow Functions

Reusing JavaScript Template Literals

Modules in JavaScript


Comments
gravatar Axel Rauschmayer Tuesday, October 11, 2016
Rollup [1] automatically prunes unused exports (“tree-shaking”) of ES6 modules. Its functionality is enabled by their static nature. v2 of webpack will also support tree-shaking. [1] http://rollupjs.org/
gravatar Machinemike Monday, October 24, 2016
(_o_)
Comments are closed.

My Pluralsight Courses

K.Scott Allen OdeToCode by K. Scott Allen
What JavaScript Developers Should Know About ECMAScript 2015
The Podcast!