Hoisting without mentioning code rearrangement

·

5 min read

Glossary

  • A binding is a variable name.
  • Variable declarators is the set of keywords that allow declaring variables in JS: var, let and const.

What’s this article about?

Well... it’s not about hoisting. It’s more about what hoisting isn’t. It assumes you already know hoisting. Aims to go a bit deeper, as deep as my current knowledge allows me to.

The misconception

Given this piece of code

const x = () => {
    y()
}

const y = () => {}

x()

, x is able to call y before y is declared because of hoisting.

Heard it in a speech not so long ago. But it wasn’t the first time.

We can do better

Hoisting doesn’t play any particular role there.

The reason why x is able to call y before y is declared is... it isn’t. The premise is wrong. x isn’t able to call y before y is declared. x is called after y is declared and initialized. Consider this instead:

const x = () => {
    y() // ReferenceError
}

x()

const y = () => {}

This does indeed throw a ReferenceError. In this case, when x is called, the JS engine doesn’t yet know about y (well, it totally does but it pretends it doesn’t because of reasons; we will touch on TDZ) and it throws an error.

So ReferenceErrors in JS are produced in execution time (given JS nature, it’s virtually impossible to determine at compile-time whether a particular binding is resolved or not). And that doesn’t have anything to do with hoisting neither, but hoisting sometimes hides that effect.

Coming back to the original piece of code:

const x = () => {
    y()
}

const y = () => {}

x()
  1. JS declares x.
  2. JS declares y.
  3. JS executes x, declared in 1.
  4. x executes y, declared in 2.

The modified one, though:

const x = () => {
    y()
}

x()

const y = () => {}
  1. JS declares x.
  2. JS executes x, declared in 1.
  3. x executes y, not yet declared ⇒ ReferenceError.

Hoisting doesn’t have any particular effect here. A function’s declaration and a function’s execution are different things. And ReferenceErrors are produced when the function that “contains them” is executed, not when it’s declared.

What’s hoisting?

Hoisting is an effect of JS code being compiled, thus knowing upfront what bindings are declared and referenced by each function.

  • That’s powerful since, before running the code, there’s room for optimizations (that’s a good chunk of the reasons why eval and with statements are considered evil: they can introduce new bindings at runtime and drastically reduce the number of assumptions and optimizations).
  • But that also means that, before code is executed, JS knows all the bindings the function declares and references. Thus it doesn’t need to throw an error when it finds a “non-yet declared variable”:

      const x = () => {
          console.log(y) // undefined
          var y = 0
      }
    

    That’s what we commonly understand by hoisting.

    If we try the same with a different variable declarator, though:

      const x = () => {
          console.log(y) // ReferenceError
          (let|const) y = 0
      }
    

    And, also commonly, it’s said that hoisting doesn’t apply to variables declared using let nor const. Huh... that seems to contradict my hypothesis. But luckily, it doesn’t. Variables declared using let and const are just made to throw if accessed before their declaration. Even if the JS engine totally knows about their existence. It’s called Temporary Dead Zone (TDZ) and it’s there to prevent JS developers from observing variables declared using const being reassigned (which would basically defeat the purpose of const entirely). Imagine const behaved like var:

      const x = () => {
          console.log(y) // undefined
          const y = 0
          console.log(y) // 0
      }
    

    Suddenly, our const, which isn’t supposed to be reassigned, seems like has been (from undefined to 0). let just behaves as const for consistency between them.

How would have hoisting been relevant, then?

Remember our former snippet that threw a ReferenceError?

const x = () => {
    y() // ReferenceError
}

x()

const y = () => {}

What if we used a different, more permissive, variable declarator?

var x = () => {
    y() // TypeError
}

x()

var y = () => {}

Ok, well, at least that’s better. Know the JS engine doesn’t lie to us and acknowledges it knows about y upfront because it has previously compiled the code, thus we don’t get a ReferenceError. Nice! However, it still produces an error: a TypeError. If we were to console.log y before executing it, we would see at that point of the program its value is undefined. So hoisting is about the JS engine knowing whether a binding exists, but not necessarily about its initialization. However, a different type of declaration, a function declaration, may come to the rescue here:

var x = () => {
    y() // TypeError
}

x()

function y() {}

This behavior is again different to any of the behaviors mentioned so far.

When a function A is executed, its formal parameters and the function declarations it may contain are initialized as part of the creation of A's execution context, while the rest of the bindings are initialized during A’s execution. That’s yet another gotcha.

A note on the “compiling” thingy

I’ve mentioned the word compile (or a form of it) several times already.

Whether you like to call JS compiled or not is purely based on your definition of being compiled/interpreted. It’s definitely not interpreted, to me.

Thinking about stuff at compile time isn’t even a requirement to understand nor to explain hoisting, but it’s a side-effect of my JS study journey through YDKJS.

Feel free to change every reference to compile to the creation of the execution context of the enclosing function and this article shall remain accurate (and probably closer to the spec, since the spec doesn’t speak in terms of compilement). However, when the spec refers to bindings being initialized at the establishment of the execution context of the enclosing function or whatever, I assume it knows about them in the first place from the moment it compiles the code, a.k.a from the moment it generates the AST. It wouldn’t make sense to me otherwise.