Erik Simmler

Internaut, software developer and irregular rambler

Why is Babel's module syntax transpilation so weird?

One of the stranger rough edges that comes with using ES6 (via Babel/Webpack) revolves around the way Babel transpiles the new module syntax. When you use a named import, Babel’s transpiled output seems almost designed to cause a bit of confusion, as it assigns the imported value to a local variable with a rather munged name.

// Original code
import { foo } from "bar";
console.log(foo);

// Babelified
var _bar = require("bar");
console.log(_bar.foo);

Live example

The insidious part of this is that it may take a while to notice. If you’re using source maps, Chrome’s inspector will happily show your original code in the sources panel. This works shockingly well right up to the point at which you drop into the debugger and attempt to use one of your imported values.

// What you want
foo

// What you get
Uncaught ReferenceError: foo is not defined

For a while a chalked this up to an oddity required to avoid namespace clashes or something. I even considered switching to “vanilla” require(...) syntax, if only for the sake of other team members. Then the ever illuminating Dr. Axel Rauschmayer wrote up a post explaining exactly what ES6 modules actually export.

In contrast to CommonJS modules, ES6 modules export bindings, live connections to values.

He goes on to give a deeper explanation, but the gist of it is that values imported via ES6 module syntax remain bound to their source. In our case, the imported foo is still bound to the foo in the bar module. If something mutates foo in the source module, our local version will update accordingly. This binding only goes one way, so attempting to change the value of foo locally will cause an error.

With that, the name munging mystery becomes a bit less mysterious. To mimic the behavior of a live binding, Babel must replace every instance of foo in your local scope with a reference to a property on the bar object. If anything over in bar causes the value of foo to change, the local code will pick up the change immediately.

Original code Babelized
bar.js: `foo` is created
export var foo = 1
var foo = 1;
exports.foo = foo;
local.js: `foo` is imported
import {foo} from 'bar'
assert(foo === 1)
var _bar = require('bar');
assert(_bar.foo === 1);
bar.js: `foo` is modified
foo = 2
exports.foo = foo = 2;
local.js: the value of `foo` reflects the change in bar.js
assert(foo === 2)
assert(_bar.foo === 2);

I’m still not sure if I’m happy with the debugging tradeoff (especially when it affects other collaborators), but there’s no question that this is a deviously pragmatic attempt to match the subtle semantics of the actual spec. Many kudos are due to Sebastian McKenzie and the contributors to Babel for their valiant efforts to bring us these wonderful goodies from the future.

Looking forward, Nick Fitzgerald is among those working to improve source maps, hopefully in ways that will help to reduce some of the impedance mismatches inherent in mixing multiple languages in a single runtime.