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 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.
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.
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.
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";
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.
The Troubles with JavaScript Classes
The Troubles with JavaScript Arrow Functions