Mauro Bringolf

Currently geeking out as WordPress developer at WebKinder and student of computer science at ETH.

Exploring the limits of static code analysis – Implementing JavaScript’s temporal dead zone in Babel

November 5, 2017
, , ,

Earlier this week I had a look at test coverage of Babel. As a student, I think contributing tests to open source is a great way of exposing yourself to the complexity of real projects. You have to find uncovered cases and figure out what situations will trigger a certain part of the code to be executed. Its like a puzzle with the nice benefit that it actually improves a projects overall quality in the long term. Babel is in a great spot with test coverage of over 85%, so it is quite hard to find untested yet testable code. I stumbled upon a file called tdz.js with 0% coverage, which means that there is no test executing any code from it. I vaguely remembered that TDZ had something to do with scoping in JavaScript and asked on the Babel Slack if the code from this file is something to be tested. It turns out that it tries to implement a feature called temporal dead zone that was added to JavaScript in ES2015, but is disabled because it caused too much trouble.

Fast forward to today: Fascinated by the problem, I spent a few hours reading old GitHub issues and playing around with test cases. This post is a summary of my findings, explaining what the temporal dead zone is and why implementing it with just var variables is hard.

What is the temporal dead zone?

Up to ES2015 all variables were declared with var and hoisted. All these variables are declared as undefined at the beginning of their scope. This means unlike in many other languages, referencing a variable before it is declared will not throw an error but just result in the value undefined. The temporal dead zone of a variable is exactly this area: Beginning of scope until variable declaration. ES2015 introduced to new variable types that are declared with let and const and it defines any reference of such a variable inside its temporal dead zone to be a runtime error. Sloppily speaking you cannot reference let and const variables before they are declared. MDN has detailed explanations1 on how it works and we will cover some examples later.

Therefore whenever Babel translates a let or const into a var it not only has to make sure that they behave as if they were block scoped, but also that references within the TDZ will throw runtime errors. Now you might ask yourself what the potential drawbacks would be if we just left these errors out. Babel currently does this. There are two aspects that I think are relevant. First of all, anyone compiling his JavaScript with Babel might get wrong understanding of how let and const variables work. Second, the day you stop compiling let and const into var your code might stop working because of a TDZ error. If you always declare your variables before you use them you will not experience any difference. Therefore we assume the percentage of code that will break because of this to be small and given the difficulty of implementing TDZ correctly it was disregarded in earlier versions of Babel.

Compiling let and const into var

I have not studied the complete source code which compiles block scoping but we can deduce the basic idea from examples. The task is translate a piece of code using block scoped variables into an “equivalent” piece of code using only function scoped variables. Consider this code:

// Compile this babeljs.io/repl2
var x = 3
if (true) {
  let x = 7
  console.log(x)
}

The inner variable shadows the outer one and x evaluates to 7 inside the block. We need to make sure all references to x behave in the same way after we translated them into var. What Babel does is creating two different variables in the outer scope and replacing all references in the inner scope:

var x = 3;
if (true) {
  var _x = 7;
  console.log(_x);
}

This covers the basic cases however there are some exceptions. But lets not worry about these and walk through a couple of examples illustrating the challenge of implementing the temporal dead zone correctly.

Examples

// https://github.com/google/traceur-compiler/issues/1382 3
var x = 5;
if (x){
  console.log(x);
  let x = 2;
}
console.log(x);

Consider the variable x inside the if-block. Its temporal dead zone stretches from the beginning of the block to its declaration. Therefore console.log should throw an error. However, the transpiled version by Babel currently logs undefined instead.

// https://github.com/babel/babel/issues/527 4
let x = x;

This should throw an error, because in the definition x is in its temporal dead zone. Note that the same variable declaration with var is perfectly fine and will result in undefined if x was not declared before. Onto some trickier ones:

// https://github.com/babel/babel/issues/527 4
f();
let x;
function f() {
    x;
}

What happens here is that the function f is hoisted to the beginning of the scope which is the very first line. Then it is called before x is declared, which means that the function body will dereference x in its temporal dead zone and an error should be thrown. However, this really depends on the order of events since the following code does not reference x in its temporal dead zone and is fine:

// https://github.com/babel/babel/issues/527 4
function f() {
    x;
}
let x;
f();

Note how the order of which references and declarations appear in source code cannot be used to determine TDZ errors. Here we have one example throwing an error where the reference appears after the declaration and one example not throwing an error where the reference appears before the declaration.

Essential difficulty and possible solutions

As the examples above suggest, getting this right in compiled code is hard. Consider the following example and remember that Babel runs during compile time, not runtime:

// Should this throw a TDZ error?
f();
let x = 1;
function f() {
  if( Math.random() > 0.5 ) {
    return x;
  } else {
    return 0;
  }
}

At compile time, you can only know if this throws a TDZ error if you can predict the value of Math.random() which you hopefully cannot. We can make it even more extreme and make the condition something that a user inputs at runtime. In any case, it is impossible to find all TDZ errors at compile time. That means a correct solution has to insert additional code that will perform checks at runtime as suggested in one of the issues5. Basically, whenever a let variable is referenced we need to check at runtime if it has been declared before and throw an error if not. The best we can hope to achieve is to prove most of them unnecessary by static analysis. Then we only need to insert the essential checks like the one above. However, Babel does not fully implement this strategy. There are cases where it misses a TDZ error as well as other bugs, which is why currently (7.0.0-beta.31) this feature is only available behind a flag.

References

  1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let#Temporal_Dead_Zone_and_errors_with_let
  2. http://babeljs.io/repl#?babili=false&browsers=&build=&builtIns=false&code_lz=G4QwTgBAHhC8EGYBQSCWAzCAKCAXMArgKYQCUEA3kgJAA2Ru0cEA7BEgL5A&debug=false&circleciRepo=&evaluate=true&lineWrap=false&presets=es2015&targets=&version=6.26.0
  3. https://github.com/google/traceur-compiler/issues/1382
  4. https://github.com/babel/babel/issues/527
  5. https://github.com/google/traceur-compiler/issues/1382#issuecomment-57199937