Updated version of this blog post: Chapter “Variables and scoping” in “Exploring ES6”.
This blog post examines how variables and scoping are handled in ECMAScript 6 [1].
let
and const
Both let
and const
create variables that are block-scoped – they only exist within the innermost block that surrounds them. The following code demonstrates that the let
-declared variable tmp
only exists inside the then-block of the if
statement:
function func() {
if (true) {
let tmp = 123;
}
console.log(tmp); // ReferenceError: tmp is not defined
}
In contrast, var
-declared variables are function-scoped:
function func() {
if (true) {
var tmp = 123;
}
console.log(tmp); // 123
}
Block scoping means that you can shadow variables within a function:
function func() {
let foo = 5;
if (···) {
let foo = 10; // shadows outer `foo`
console.log(foo); // 10
}
console.log(foo); // 5
}
const
creates immutable variables Variables created by let
are mutable:
let foo = 'abc';
foo = 'def';
console.log(foo); // def
Variables created by const
, constants, are immutable:
const foo = 'abc';
foo = 'def'; // TypeError
Note that const
does not affect whether the value of a constant itself is mutable or not: If a constant refers to an object, it will always refer to that object, but the object itself can still be changed (if it is mutable).
const obj = {};
obj.prop = 123;
console.log(obj.prop); // 123
obj = {}; // TypeError
If you wanted obj
to truly be a constant, you’d have to freeze its value:
const obj = Object.freeze({});
obj.prop = 123; // TypeError
const
in loop bodies Once a const
variable has been created, it can’t be changed. But that doesn’t mean that you can’t re-enter its scope and start fresh, with a new value. For example, via a loop:
function logArgs(...args) {
for (let [index, elem] of args.entries()) {
const message = index + '. ' + elem;
console.log(message);
}
}
logArgs('Hello', 'everyone');
// Output:
// 0. Hello
// 1. everyone
let
, when const
? If you want to mutate a variable that holds a primitive value, you can’t use const
:
const foo = 1;
foo++; // TypeError
However, you can use a const
variable to refer to something mutable:
const bar = [];
bar.push('abc'); // OK, the array is mutable
I’m still mulling over what the best style is, but I currently use let
in situations like the previous example, because bar
refers to something mutable. I do use const
to indicate that both variable and value are immutable:
const EMPTY_ARRAY = Object.freeze([]);
A variable declared by let
or const
has a so-called temporal dead zone (TDZ): When entering its scope, it can’t be accessed (got or set) until execution reaches the declaration.
Let’s first examine the life cycle of var
variables, which don’t have temporal dead zones:
When the scope (its surrounding function) of a var
variable is entered, storage space (a so-called binding) is created for it. The variable is immediately initialized, by setting it to undefined
.
When the execution within the scope reaches the declaration, the variable is set to the value specified by the initializer (an assignment) – if there is one. If there isn’t, the value value of the variable remains undefined
.
Variables declared via let
have temporal dead zones, which means that their life cycles look like this:
When the scope (its surrounding block) of a let
variable is entered, storage space (a so-called binding) is created for it. The variable remains uninitialized.
Getting or setting an uninitialized causes a ReferenceError.
When the execution within the scope reaches the declaration, the variable is set to the value specified by the initializer (an assignment) – if there is one. If there isn’t, the value of the variable is set to undefined
.
const
variables work similarly to let
variables, but they must have an initializer (i.e., be set to a value immediately) and can’t be changed.
Within a TDZ, an exception is thrown if a variable is got or set:
if (true) { // enter new scope, TDZ starts
// Uninitialized binding for `tmp` is created
tmp = 'abc'; // ReferenceError
console.log(tmp); // ReferenceError
let tmp; // TDZ ends, `tmp` is initialized with `undefined`
console.log(tmp); // undefined
tmp = 123;
console.log(tmp); // 123
}
The following example demonstrates that the dead zone is really temporal (based on time) and not spatial (based on location):
if (true) { // enter new scope, TDZ starts
const func = function () {
console.log(myVar); // OK!
};
// Here we are within the TDZ and
// accessing `myVar` causes a ReferenceError
let myVar = 3; // TDZ ends
func(); // called outside TDZ
}
typeof
and the temporal dead zone A variable being unaccessible in the temporal dead zone means that you can’t even apply typeof
to it:
if (true) {
console.log(typeof tmp); // ReferenceError
let tmp;
}
I don’t expect this to be a problem in practice, because you can’t conditionally add let
-declared variables to a scope. In contrast, you can do so for var
-declared variables; assigning to a property of window
creates a global var
variable:
if (typeof myVarVariable === 'undefined') {
// `myVarVariable` does not exist => create it
window.myVarVariable = 'abc';
}
let
in loop heads In loops, you get a fresh binding for each iteration if you let
-declare a variable. The loops that allow you to do so are: for
, for-in
and for-of
.
This looks as follows:
let arr = [];
for (let i=0; i < 3; i++) {
arr.push(() => i);
}
console.log(arr.map(x => x())); // [0,1,2]
In contrast, a var
declaration leads to a single binding for the whole loop (a const
declaration works the same):
let arr = [];
for (var i=0; i < 3; i++) {
arr.push(() => i);
}
console.log(arr.map(x => x())); // [3,3,3]
Getting a fresh binding for each iteration may seem strange at first, but it is very useful whenever you use loops to create functions (e.g. callbacks for event handling) that refer to loop variables.
If you let
-declare a variable that has the same name as a parameter, you get a static (load-time) error:
function func(arg) {
let arg; // static error: duplicate declaration of `arg`
}
Doing the same inside a block shadows the parameter:
function func(arg) {
{
let arg; // shadows parameter `arg`
}
}
In contrast, var
-declaring a variable that has the same name as a parameter does nothing, just like re-declaring a var
variable within the same scope does nothing.
function func(arg) {
var arg; // does nothing
}
function func(arg) {
{
// We are still in same `var` scope as `arg`
var arg; // does nothing
}
}
If parameters have default values [2], they are treated like a sequence of let
statements and are subject to temporal dead zones:
// OK: `y` accesses `x` after it has been declared
function foo(x=1, y=x) {
return [x, y];
}
foo(); // [1,1]
// Exception: `x` tries to access `y` within TDZ
function bar(x=y, y=2) {
return [x, y];
}
bar(); // ReferenceError
The scope of parameter default values is separate from the scope of the body (the former surrounds the latter). That means that methods or functions defined “inside” parameter default values don’t see the local variables of the body:
let foo = 'outer';
function bar(func = x => foo) {
let foo = 'inner';
console.log(func()); // outer
}
bar();
JavaScript’s global object (window
in web browsers, global
in Node.js) is more a bug than a feature, especially with regard to performance. That’s why it’s not surprising that ES6 introduces a distinction:
var
declarationslet
declarationsconst
declarationsFunction declarations…
let
.var
.The following code demonstrates the hoisting of function declarations:
{ // Enter a new scope
console.log(foo()); // OK, due to hoisting
function foo() {
return 'hello';
}
}
Class declarations…
Classes not being hoisted may be surprising, because, under the hood, they create functions. The rationale for this behavior is that the values of their extends
clauses are defined via expressions and those expressions have to be executed at the appropriate times.
{ // Enter a new scope
const identity = x => x;
// Here we are in the temporal dead zone of `MyClass`
let inst = new MyClass(); // ReferenceError
// Note the expression in the `extends` clause
class MyClass extends identity(Object) {
}
}
Using ECMAScript 6 today [an early draft of my book on ECMAScript 6] ↩︎