Tales from "Ecma's Crypt": Annex B.3.3 👻
Tags: ecmascript, tc39, javascript
Published: 2020-03-30 01:32:46

Arguably the most horrifying part of the [ECMAScript specification](https://tc39.es/ecma262/) concerns the question:

> "What happens if I declare a function inside a block in sloppy mode?"

To start with an intentionally overwhelming example, would you believe that the following prints `1`? 😳
```js
var a = -1;
(function () {
  const printOuter = () => console.log(a);
  {
    a = 1;
    function a() {}
    a = 2;
    printOuter();
  }
})();
```
Indeed, it's a bit much to dive right into the deep end, so let's start at the beginning and work our way up to it.

# Before ES2015

`var`s, `function`s, and blocks have all been around since ES1, but blocks were originally only meant for turning a list of statements into a statement itself, and not usable as a scope for declarations. Since blocks were needed for `if` branches, `while` bodies, and such, it was no extra burden to allow standalone blocks too (whether this benefitted developers or not).

Now, it's always been possible to declare a `var` from within a block, but it'd still be hoisted to the top of the immediate function (or script). Hence these two snippets have the same observable behavior:
```js
console.log(a); // undefined
{
  var a = 3;
}
console.log(a); // 3
```
```js
var a;
console.log(a); // undefined
a = 3;
console.log(a); // 3
```

On the other hand, even as late as ES5, function declarations in blocks were not part of the spec at all!

# The Post-`var` World

One of ES2015's biggest innovations was introducing lexical `let` and `const` (and `class`!) declarations, which do in fact make use of blocks as scopes.

Block-scoped variables exhibit their own sort of hoisting-like behavior: from the beginning of the block until their point of initialization, they are said to be in the "temporal dead zone" (TDZ), meaning that the following is mercifully an error:
```js
var a;
{
  a = 3; // ReferenceError!
  let a;
}
```

ES2015 also introduced function declarations in blocks. And, being a new addition to the spec, it was naturally decided that they too should be scoped to the block!

Note though, that there is no TDZ for function declarations, since it's important to be able to call functions that are declared later on. Effectively, the initialization gets raised to the top as well:
```js
{
  // let a = function () {};
  a();
  function a() {} // nothing happens here
}
```

# Web Reality

But alas, the web is not so simple. JS engines are free to extend the language in various ways (except where [forbidden](https://tc39.es/ecma262/#sec-forbidden-extensions)), and function declarations in blocks were quite desirable, even before ES2015 and its fancy block scoping. So engines did implement this feature, and without spec text to keep them aligned, they implemented it in mutually incompatible ways.

And so, while the main body of the spec accurately describes the behavior of this feature in strict mode, a section called [Annex B.3.3](https://tc39.es/ecma262/#sec-block-level-function-declarations-web-legacy-compatibility-semantics) was added to describe the "intersection semantics" of these divergent extensions—i.e., the (un)happy path that browser-hosted engines would need to support in order to achieve web compatibility in sloppy mode.

This essentially amounts to treating a function declaration in a block as a `let` _and_ a `var` at the same time. 🤯

That is, we have the following effects in sloppy mode:
```js
// var a;
...
{
  // let a = function () {};
  ...
  function a() {} // (var) a = (let) a; <-- not actually writable in surface code
}
```

## The Deep End

And with that, you're equipped to understand the following grand example (courtesy of fellow TC39er [Kevin Gibbons](https://github.com/bakkot)):
```js
var a = -1;
(function () {
  // var a;
  const printOuter = () => console.log('outer:', a);
  {
    // let a = function () {}; 
    const printInner = () => console.log('inner:', a);

    printOuter(); // outer: undefined
    printInner(); // inner: function a(){}

    a = 1;
    printOuter(); // outer: undefined
    printInner(); // inner: 1

    function a() {} // (var) a = (let) a;
    printOuter(); // outer: 1
    printInner(); // inner: 1

    a = 2;
    printOuter(); // outer: 1
    printInner(); // inner: 2
  }
})();
console.log('outermost:', a); // outermost: -1
```
Consider it a cautionary tale to always use strict mode (for which you'll get `outer: -1` across the board). 😉