• Home
  • About

JavaScript's let, const, and the TDZ

Digging into the V8 engine to understand how JavaScript variables really work


JavaScript's let, const, and the TDZ

In this post, I want to take a deep dive into the let and const keywords that were introduced in JavaScript ES6. I’m a bit embarrassed to admit this, but until recently, I believed that let and const weren’t hoisted. Then, during a conversation with a friend, I learned that let and const are indeed hoisted — they just use a special mechanism called the TDZ to prevent access before initialization.


Someone: Why do let and const throw reference errors, unlike var?

Me: let and const don’t get hoisted.

Friend: They do get hoisted… The reference error happens because they’re in the TDZ.

Me: They do??? What’s a TDZ?


no yes Wait... they get hoisted...?

After this humbling experience, I decided to properly study variable declaration keywords — and that’s how this post came to be.

The var Keyword

The var keyword was the only way to declare variables in JavaScript through ES5. It worked quite differently from other languages, which made it notorious for confusing developers coming from other programming backgrounds.

Here are the key characteristics of the var keyword:

Variables can be re-declared

var name = 'Evan';
var name = 'Evan2';
console.log(name) // Evan2

In this code, the declarations are close together, so it’s easy to spot that name is declared twice. But imagine 500 lines of code between the first and second declaration — now you’ve got a serious problem. Allowing duplicate declarations creates real potential for unintended variable mutations.

It gets hoisted

Hoisting essentially means pulling all declarations within a scope to the top of that scope. This happens because JavaScript interpreters process code in two phases: first, variable and function declarations; second, actual code execution. This behavior can be quite confusing.

console.log(name); // undefined
var name = 'Evan';

When I first started with JavaScript, I thought “why doesn’t this throw a reference error?” The thing is, hoisting causes this code to behave like:

var name; // Declaration gets pulled to the top

console.log(name);
name = 'Evan';

Of course, this is just how the JavaScript interpreter processes the code internally — the actual lines in your source file don’t move.

Anyway, when you get hoisted (I feel like “get hoisted” fits well here — it has happened to me many times…), even though JavaScript is an interpreted language, the order you read the code and the order it executes can differ. This is one of the factors that makes JavaScript confusing for beginners.

Function-level scope

Most programming languages use block-level scoping, but JavaScript had to be different. Variables declared with var are only scoped to functions. This isn’t a big issue for developers familiar with JavaScript, but it’s definitely confusing for beginners.

(function () {
  var local = 1;
})();
console.log(local); // Uncaught ReferenceError: local is not defined

for (var i = 0; i < 10; i++) {}
console.log(i); // 10

Because only function scope is recognized, even a variable i declared inside a for loop is accessible from outside.

The var keyword can be omitted

You can declare a variable with or without the var keyword. Typical JavaScript freedom. So much freedom that developers can never let their guard down.

var globalVariable = 'global!';

if (globalVariable === 'global!') {
  globlVariable = 'global?' // typo
}

console.log(globalVariable) // global!
console.log(globlVariable) // global?

I accidentally typed globalVariable as globlVariable. A naive developer would expect globalVariable to have the value global?, but unfortunately that value went to the typo’d variable name globlVariable instead.

Cases like this are easy to debug in simple code, but once the code gets even slightly complex, you’ll be in tears.

off work Get caught by one of these, and you'll be working until 11pm and taking a taxi home

Enter let and const

The var keyword makes it easy to create global variables, and even local variables have such wide scope that tracking down where a variable was declared or how its value unexpectedly changed can be difficult.

That’s why JavaScript ES6, released in 2015, introduced new variable declaration keywords: let and const.

The let keyword is used to declare variables, just like var. The const keyword is used to declare constants — meaning you can’t reassign the literal value.

const callEvan = 'Hello, Evan!';
callEvan = 'Bye, Evan!'; // Uncaught TypeError: Assignment to constant variable.

So how are these different from the old var keyword? The characteristics of var I described above were:


  • Variables can be re-declared.
  • It gets hoisted.
  • It uses function-level scope, not block-level scope.
  • The var keyword can be omitted.

These four points. Now let’s look at how let and const contrast with them.

Characteristics of let and const

Let’s first examine the points that contrast with the var keyword characteristics described above.

Variables cannot be re-declared

Unlike var, once you declare a variable with these keywords, you can’t re-declare it.

var name = 'Evan';
var name = 'Evan2'; // nothing happens...

let name = 'Evan';
let name = 'Evan2'; // Uncaught SyntaxError: Identifier 'name' has already been declared

const name = 'Evan';
const name = 'Evan2'; // Uncaught SyntaxError: Identifier 'name' has already been declared

This prevents scenarios where you accidentally declare a variable twice, change its value unintentionally, and end up working overtime.

Hoisted?

This is exactly why I wrote this post. I thought let and const weren’t hoisted.

But as I explained above, hoisting is something that happens during the JavaScript interpreter’s code parsing phase — there’s no way let or const can escape it. So how did they solve this problem?

Different errors depending on the declaration keyword

console.log(name); // undefined
var name = 'Evan';

console.log(aaa) // Uncaught ReferenceError: aaa is not defined

console.log(name); // Uncaught ReferenceError: Cannot access 'name' before initialization
let name = 'Evan';

The first example shows accessing a variable declared with var that got hoisted. Naturally, no reference error — it just cleanly outputs undefined.

The second example shows accessing a variable that was never declared at all. We get Uncaught ReferenceError with the message aaa is not defined.

The third example shows accessing a variable declared with let before its declaration. We also get Uncaught ReferenceError, but the message is different: Cannot access 'name' before initialization.

Let’s dig into the V8 engine

These two errors are completely different, and in the V8 engine’s internal MESSAGE_TEMPLATE, they’re explicitly distinguished and called in different cases.

T(NotDefined, "% is not defined")
T(AccessedUninitializedVariable, "Cannot access '%' before initialization")

After cloning V8’s GitHub repository and examining it, I found numerous places where the code branches based on whether a JS object was declared with var versus let or const. As I continued analyzing the code, I confirmed that hoisting always occurs regardless of which keyword you use to declare a value. The V8 engine’s internal hoisting flag should_hoist is always set to true when assigning to a JavaScript object, without any distinction based on the variable declaration keyword.

So where does the difference between var and let/const come from? While they’re all treated identically when declaring variables — that is, when V8 creates variable objects — they’re handled differently during the initialization phase, when memory space is allocated for the variable.

static InitializationFlag DefaultInitializationFlag(VariableMode mode) {
  DCHECK(IsDeclaredVariableMode(mode));
  return mode == VariableMode::kVar ? kCreatedInitialized
                                    : kNeedsInitialization;
}

Looking at the subsequent logic, there’s a function called DefaultInitializationFlag that returns a VariableKind type used internally by V8. Variables declared with var get kCreatedInitialized, while variables declared with let and const get kNeedsInitialization.

In other words, literal values declared with let or const are hoisted, but they’re managed in a “needs initialization” state for a special reason.

What does “needs initialization” mean?

Inside the JavaScript interpreter, variables are created in three stages:


  • Declaration: A scope and variable object are created, and the scope references the variable object.
  • Initialization: Memory space is allocated for the value the variable object will hold. At this point, it’s initialized to undefined.
  • Assignment: A value is assigned to the variable object.

For objects declared with var, declaration and initialization happen simultaneously. The moment it’s declared, it’s initialized to undefined.

// v8/src/parsing/parser.cc
// When declaring a variable in Var mode
auto var = scope->DeclareParameter(name, VariableMode::kVar, is_optional,
                                         is_rest, ast_value_factory(), beg_pos);
var->AllocateTo(VariableLocation::PARAMETER, 0);

Looking at V8’s code, after creating a variable object in kVar mode, the AllocateTo method is immediately called to allocate memory space. But variable objects created with let or const are different.

// v8/src/parsing/parser.cc
// When declaring a variable in kLet mode
VariableProxy* proxy =
      DeclareBoundVariable(variable_name, VariableMode::kLet, class_token_pos);
proxy->var()->set_initializer_position(end_pos);

// When declaring a variable in Const mode
VariableProxy* proxy =
          DeclareBoundVariable(local_name, VariableMode::kConst, pos);
proxy->var()->set_initializer_position(position());

Variable objects created in kLet or kConst mode don’t have AllocateTo called immediately. Instead, you can see they only set a position value indicating where the code is in the source file.

This is exactly the moment when variables created with let or const enter the TDZ (Temporal Dead Zone). In other words, a variable in the TDZ is “declared but not yet initialized — memory hasn’t been allocated for the value it will hold.”

If you try to access such a variable, you’ll inevitably encounter the Cannot access '%' before initialization error message.

Block-level scope

Unlike var which uses function-level scope, let and const use block-level scope. Because var doesn’t use block-level scope, variables declared inside a block are treated as global variables.

var globalVariable = 'I am global';

if (globalVariable === 'I am global') {
  var globalVariable = 'am I local?';
}

console.log(globalVariable); // am I local?

But with let and const, variables declared inside a block are treated as local variables.

let globalVariable = 'I am global';

if (globalVariable === 'I am global') {
  let globalVariable = 'am I local?';
  let localVariable = 'I am local';
}

console.log(globalVariable); // I am global
console.log(localVariable); // Uncaught ReferenceError: localVariable is not defined

In this case, localVariable declared inside the block is treated as a local variable and can’t be referenced from the global scope. Note that let and const also get hoisted at the block level.

let name = 'Global Evan';

if (name === 'Global Evan') {
  console.log(name); // Uncaught ReferenceError: Cannot access 'name' before initialization
  let name = 'Local Evan';
}

You might expect the if block to output Global Evan — the value of the globally declared name variable. But hoisting also occurs inside the if block, pulling the local name declaration to the top of the block, which causes the reference error.

let and const keywords cannot be omitted

name = 'Evan'
// The above code is equivalent to
var name = 'Evan'

If you don’t use a variable declaration keyword, it’s treated as if you used var, so you must always include the keyword.

Characteristics specific to const

Aside from the reasons explained above, the let keyword essentially serves the same role as var in that it’s used to declare variables. Now let’s look more closely at const, which has a different role from the traditional var keyword.

Used to declare constants

As mentioned above, const is a keyword for declaring constants. A constant represents an immutable value. In other words, once you declare a value with const, you can never change it again.

const maxCount = 30;
maxCount = 40; // Uncaught TypeError: Assignment to constant variable.

If you try to reassign a value declared with const, you’ll get a friendly error message telling you it’s not allowed.

But there’s an important point here. What happens when you declare a type that uses call by reference with const?

const obj = { name: 'Evan' }
obj = { name: 'John' } // Uncaught TypeError: Assignment to constant variable.

In this case, we get an error because we’re trying to change the reference that obj points to. However, the properties inside the object aren’t affected by the const keyword.

const obj = { name: 'Evan' }
obj.name = 'John'
console.log(obj) // { name: 'John' }

This applies equally to Array, another type that uses call by reference. Even if you declared it with const, there’s no restriction on using push or splice to modify the elements inside the array.

Must be initialized at declaration

With let, even if you only explicitly declare a variable, the interpreter implicitly initializes it to undefined when it processes that line.

let hi;
console.log(hi); // undefined

But when using const, you must assign a value at the time of declaration.

const hi; // Uncaught SyntaxError: Missing initializer in const declaration

Conclusion

Unless you’re in the unfortunate situation of having to write JavaScript ES5 code, I recommend never using the var keyword anymore.

That leaves let and const, which raises the question: “how should I use these two?”

Having used only var for so long, I hadn’t really thought about this, but there are actually not that many cases where you absolutely need to reassign a variable. I was coding one day and looked back at my code — most variables were declared with const, and let was only used in a few places.

If you do need to use let, I recommend never using it in the global scope — keep block scopes small and use it within those.

As for const, it immediately throws an error if you try to reassign, which prevents the sad situation of accidentally changing a variable’s value. So make good use of const for safe coding, and may you all leave work on time instead of staying late.

off work2 Time to go home, eat dinner, and watch Netflix!

That wraps up this post on JavaScript’s let, const, and the TDZ.

관련 포스팅 보러가기

Jun 28, 2019

How Does the V8 Engine Execute My Code?

Programming/JavaScript
Oct 27, 2019

[JS Prototypes] Implementing Inheritance with Prototypes

Programming/JavaScript
Oct 23, 2019

Beyond Classes: A Complete Guide to JavaScript Prototypes

Programming/JavaScript
Jun 25, 2019

Diving Deep into Hash Tables with JavaScript

Programming/Algorithm
Oct 12, 2019

Heaps: Finding Min and Max Values Fast

Programming/Algorithm