What is purpose of let
getting hoisted to top when it will throw an error on accessing?
It's so we can have block scope, which is fairly understandable concept, without having the block equivalent of var
hoisting, which is a traditional source of bugs and misunderstandings.
Consider the inside of this block:
{
let a = 1;
console.log(a);
let b = 2;
console.log(a, b);
let c = 3;
console.log(a, b, c);
}
The designers had three main choices here:
- Have block scope but with all the declarations hoisted to the top and accessible (like
var
is in functions); or
- Don't have block scope, and instead have a new scope start with every
let
, const
, class
, etc.; or
- Have block scope, with hoisting (or what I call "half-hoisting"), where the declarations are hoisted, but the identifiers they declare are inaccessible until they're reached in the code
Option 1 leaves us open to the same kinds of bugs we have with var
hoisting. Option 2 is way more complicated for people to understand, and more work for JavaScript engines to do (details below if you want them). Option 3 hits the sweet spot: Block scope is easy to understand and implement, and the TDZ prevents bugs like those caused by var
hoisting.
Also do var
also suffer from TDZ,I know when it will throw undefined
but is it because of TDZ?
No, var
declarations have no TDZ. undefined
isn't thrown, it's just the value a variable has when it's declared but not set to anything else (yet). var
declarations are hoisted to the top of the function or global environment and fully accessible in that scope, even before the var
line is reached.
It may help to understand how identifier resolution is handled in JavaScript:
The specification defines it in terms of something called a lexical environment, which contains an environment record, which contains information about the variables, constants, function parameters (if relevant), class
declarations, etc. for the current context. (A context is a specific execution of a scope. That is, if we have a function called example
, the body of example
defines a new scope; every time we call example
, there are new variables, etc., for that scope — that's the context.)
The information about an identifier (variable, etc.), is called a binding. It contains the identifier's name, its current value, and some other information about it (like whether it's mutable or immutable, whether it's accessible [yet], and so on).
When code execution enters a new context (for instance, when a function is called, or we enter a block containing a let
or similar), the JavaScript engine creates* a new lexical environment object (LEO), with its environment record (envrec), and gives the LEO a link to the "outer" LEO that contains it, forming a chain. When the engine needs to look up an identifier, it looks for a binding in the envrec of the topmost LEO and, if found, uses it; if not found, looks at the next LEO in the chain, and so on until we reach the end of the chain. (You've probably guessed: The last link in the chain is for the global environment.)
The changes in ES2015 to enable block scope and let
, const
, etc. were basically:
- A new LEO may be created for a block, if that block contains block-scoped declarations
- Bindings in an LEO may be marked "inaccessible" so the TDZ can be enforced
With all that in mind, let's look at this code:
function example() {
console.log("alpha");
var a = 1;
let b = 2;
if (Math.random() < 0.5) {
console.log("beta");
let c = 3;
var d = 4;
console.log("gamma");
let e = 5;
console.log(a, b, c, d, e);
}
}
When example
is called, how does the engine handle that (at least, in terms of the spec)? Like this:
- It creates an LEO for the context of the call to
example
- It adds bindings for
a
, b
, and d
to that LEO's envrec, all with the value undefined
:
a
is added because it's a var
binding located anywhere in the function. Its "accessible" flag is set to true (because of var
).
b
is added because it's a let
binding at the top level of the function; its "accessible" flag is set to false because we haven't reached the let b
line yet.
d
because it's a var
binding, like a
.
- It executes
console.log("alpha")
.
- It executes
a = 1
, changing the value of the binding for a
from undefined
to 1
.
- It executes
let b
, changing the b
binding's "accessible" flag to true.
- It executes
b = 2
, changing the value of the binding for b
from undefined
to 2
.
- It evaluates
Math.random() < 0.5
; let's say it's true:
- Because the block contains block-scoped identifiers, the engine creates a new LEO for the block, setting its "outer" LEO to the one created in Step 1.
- It adds bindings for
c
and e
to that LEO's envrec, with their "accessible" flags set to false.
- It executes
console.log("beta")
.
- It executes
let c = 3
, setting c
's binding's "accessible" flag to true and setting its value to 3
- It executes
d = 4
.
- It executes
console.log("gamma")
.
- It executes
let e = 5
, setting e
's binding's "accessible" flag to true and setting its value to 5
.
- It executes
console.log(a, b, c, d, e)
.
Hopefully that answers:
- Why we have
let
half-hoisting (to make it easy to understand scope, and to avoid having too many LEOs and envrecs, and to avoid bugs at the block level like the ones var
hoisting had at the function level)
- Why
var
doesn't have a TDZ (a var
variable's binding's "accessible" flag is always true)
* At least, that's what they do in terms of the specification. In fact, they can do whatever they like provided it behaves as the specification defines. In fact, most engines will do things that are more efficient, utilizing a stack, etc.