The leaning tower of Babel
An exploration of Babel's caveats.
About Babel
Babel is the most popular ES6 (aka ES2015) compiler right now. It takes code written in ES6 and turns it into code that runs in any ES5 JavaScript engine. ES5 is the overwhelming standard in JavaScript engines right now, but that is ever-changing, as evidenced by the ES6 compatibility table.
Babel allows you to “Use next generation JavaScript, today”, but of course there are caveats. It involves an additional build step (ES6-to-ES5 compilation), a Babel configuration file, and many plugins. Depending on what tools you integrate with, you may need Babel in multiple places. The setup page details many integrations.
What is this post about?
The purpose of this blog post is not to discuss the upfront costs (extra compilation step, extra dependencies, difficulty of configuration), but to discuss the eventual costs of using Babel. Like most abstractions, Babel leaks. What I mean specifically is that Babel in many cases cannot (or chooses not to) 100% correctly produce the semantics of ES6 in the resulting ES5 code.
Why so picky about semantics?
We all know that 2 + 2 === 4
. What if at some point in the future
2 + 2 === 4.0001
? That’s almost right. That kind of error would not be
acceptable in a banking app where people’s money is on the line. You might
display numbers rounded to the nearest cent, but minor errors will compound over
time and produce problems.
Babel’s ES6 compatibility is like that—it’s almost right. For many scenarios, it’s correct enough that you won’t observe anything weird. But imagine one day Babel fixes its semantics, or more likely, ES6 becomes so widely supported you drop Babel in favor of native ES6. If you do that, your application might suddenly have massive bugs! At this point, whatever application you wrote right now in 2016 is probably considered legacy, and you’re working at a different job, so now the new hire is tasked with your big blob of Babel-dependent code to fix for ES6, or never stop using Babel!
Many examples
ES6 has a long and complicated spec. Writing a JavaScript engine that completely adheres to the spec is a lot of work. Writing an ES6-to-ES5 compiler that produces small output and adheres to the ES6 spec is practically impossible. I am only outlining the examples that I have seen or that others have pointed out to me.
Arrow function new
One of the most exciting features of ES6 is the arrow function. Essentially,
(x, y) => x + y
is like function(x, y) { return x + y; }
, but there’s a lot
more nuance than that. One thing about arrow functions is that it’s an error
to call new
on one. So this code snippet should throw an error in a compliant
ES6 engine.
const Person = () => {
console.log(this);
};
Person.prototype = {
setName(name) {
this.name = name;
},
};
const amina = new Person();
amina.setName("Amina");
console.log(amina.name);
Babel compromises and just emits a regular function here, so the code works without errors.
"use strict";
var Person = function Person() {
console.log(undefined);
};
Person.prototype = {
setName: function setName(name) {
this.name = name;
},
};
var amina = new Person();
amina.setName("Amina");
console.log(amina.name);
Admittedly, Babel at least transforms direct use of this
inside the arrow
function into undefined
now, so errors should be frequently caught sooner.
I noticed this error when I was using Mithril which calls user supplied
functions using new
, without making this clear in its documentation.
Sloppy arguments
ES6 arrow functions do not get arguments
or this
variables, but since Babel
compiles to plain ES5 functions, it has to do some tricks to fix this.
Unfortunately you can break this right now.
const global = 0 || window;
global.arguments = 2;
const f = () => arguments;
console.log(f());
That should print 2
, but in Babel it references an undeclared variable.
"use strict";
var _arguments = arguments;
var global = 0 || window;
global.arguments = 2;
var f = function f() {
return _arguments;
};
console.log(f());
Symbols
ES6 symbols are a fairly complicated feature that really can’t be compiled easily. Unfortunately, Babel ships with two different Symbols compilation modes, both with large caveats. Technically the “library” portions of ES6 are covered by the core-js project, but Babel encourages you to use it.
Global Symbols
The first polyfill route for symbols is to put all symbol keys as properties on
Object.prototype
, meaning that a seemingly harmless loop like this actually
creates a massive memory leak.
for (var i = 0; i < 999999; i++) Symbol();
This polyfill technique creates a non-enumerable property on
Object.prototype
every time you create a new symbol, which leads to incorrect
ownership semantics, in addition to the memory leak.
var s1 = Symbol();
var s2 = Symbol();
s1 in Object.prototype; // true, but shouldn't be
s2 in Object.prototype; // true, but shouldn't be
s1 in {}; // true, but shouldn't be
s2 in {}; // true, but shouldn't be
Object.keys({ a: 1 }); // ["a"], which is correct
Funny keys
The other option is just to put the “symbol” keys into an object using a funny
name that looks like gibberish. This clever hack is done by producing an object
with a funny toString
method, since objects are converted via toString
automatically when used as keys to other objects. The implementation could look
something like this:
var i = 0;
function Symbol(tag) {
tag = tag || "";
var str = "#Symbol(" + tag + ")#" + i;
return {
toString: function () {
return str;
},
};
}
var s = Symbol("nice");
var o = {};
o[s] = 1; // {"#Symbol(nice)#0": 1}
This hack is simple and won’t leak memory, but obviously it can cause problems
with anything trying to enumerate the keys in an object, such as Object.keys
or a for (k in obj)
loop.
Weird typeof
The operator typeof
is not extensible in ES5, so that also means that any use
of typeof
has to be converted into something more complicated so the new type
"symbol"
can be returned. This is how Babel currently handles it:
// typeof foo === "symbol" becomes...
(typeof foo === "undefined" ? "undefined" : _typeof(foo)) === "symbol";
Generators / async+await
Generators and async
+ await
are very powerful features, but have to do with
flow control in such a way that compiled output is extremely large and difficult
to debug, plus generators still require a runtime library too. Look at how this
simple example balloons in complexity.
function* nice() {
yield 1;
yield 2;
yield 3;
}
for (let x of nice()) {
console.log(x);
}
async function asyncAdd() {
const x = await getA();
const y = await getB();
return x + y;
}
Now remember that this compiled output needs a whole separate runtime library besides core-js which is called regenerator.
"use strict";
var asyncAdd = (function () {
var ref = _asyncToGenerator(
regeneratorRuntime.mark(function _callee() {
var x, y;
return regeneratorRuntime.wrap(
function _callee$(_context2) {
while (1) {
switch ((_context2.prev = _context2.next)) {
case 0:
_context2.next = 2;
return getA();
case 2:
x = _context2.sent;
_context2.next = 5;
return getB();
case 5:
y = _context2.sent;
return _context2.abrupt("return", x + y);
case 7:
case "end":
return _context2.stop();
}
}
},
_callee,
this
);
})
);
return function asyncAdd() {
return ref.apply(this, arguments);
};
})();
function _asyncToGenerator(fn) {
return function () {
var gen = fn.apply(this, arguments);
return new Promise(function (resolve, reject) {
function step(key, arg) {
try {
var info = gen[key](arg);
var value = info.value;
} catch (error) {
reject(error);
return;
}
if (info.done) {
resolve(value);
} else {
return Promise.resolve(value).then(
function (value) {
return step("next", value);
},
function (err) {
return step("throw", err);
}
);
}
}
return step("next");
});
};
}
var _marked = [nice].map(regeneratorRuntime.mark);
function nice() {
return regeneratorRuntime.wrap(
function nice$(_context) {
while (1) {
switch ((_context.prev = _context.next)) {
case 0:
_context.next = 2;
return 1;
case 2:
_context.next = 4;
return 2;
case 4:
_context.next = 6;
return 3;
case 6:
case "end":
return _context.stop();
}
}
},
_marked[0],
this
);
}
var _iteratorNormalCompletion = true;
var _didIteratorError = false;
var _iteratorError = undefined;
try {
for (
var _iterator = nice()[Symbol.iterator](), _step;
!(_iteratorNormalCompletion = (_step = _iterator.next()).done);
_iteratorNormalCompletion = true
) {
var x = _step.value;
console.log(x);
}
} catch (err) {
_didIteratorError = true;
_iteratorError = err;
} finally {
try {
if (!_iteratorNormalCompletion && _iterator.return) {
_iterator.return();
}
} finally {
if (_didIteratorError) {
throw _iteratorError;
}
}
}
Default arguments
ES6 default arguments are not scoped properly in Babel. This example should
throw an exception when calling f
:
function f(x = x) {
return x;
}
console.log(f());
But instead Babel produces code that simply returns undefined
.
"use strict";
function f() {
var x =
arguments.length <= 0 || arguments[0] === undefined ? x : arguments[0];
return x;
}
console.log(f());
ES6 modules
Possibly one of the most anticipated features of ES6 is modules. Unfortunately, all that was really standardized with ES6 is the syntax of ES6 modules, and the semantics of module bindings. Any kind of interoperability with CommonJS was left unmentioned, and even the meaning of the module identifier was left completely to another (unfinished) spec to decide.
Because of all this, Babel has to make pragmatic decisions such that developers can use it now, and can continue to use the many CommonJS packaged JavaScript libraries available on npm.
import * as foo from "bar";
import foo from "bar";
Those two lines should have completely different semantics, but Babel makes them equivalent. This will probably break a lot of code that depends on Babel’s specific compilation strategy for ES6 modules.
export { a, b, c };
export default { a, b, c };
Confusingly, only the second export form is generating a JavaScript object, the first is merely performing multiple exports on the same line. Since Babel has to work with ES5 though, both forms generate objects at runtime, further confusing how ES6 modules actually work.
Conclusion
This is not to say that Babel, core-js, or regenerator are bad projects, or that you shouldn’t use them. My point in writing this is that I don’t see anyone talking about the issues with using these tools, and the issues with eventually not using these tools any more.
I don’t use them in my personal projects mostly due to the added complexity. But this is your call, or your team’s call.