Variables and scoping in ECMAScript 6
This blog post examines how variables and scoping are handled in ECMAScript 6 [1].
Block scoping via 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
When should I use 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([]);
The temporal dead zone
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 toundefined
.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.
Parameters
Parameters versus local 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
}
}
Parameter default values and the temporal dead zone
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
Parameter default values don’t see the scope of the body
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();
The global object
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:
- All properties of the global object are global variables. In global scope, the following declarations create such properties:
var
declarations- Function declarations
- But there are now also global variables that are not properties of the global object. In global scope, the following declarations create such variables:
let
declarationsconst
declarations- Class declarations
Function declarations and class declarations
Function declarations…
- are block-scoped, like
let
. - create properties on the global object (while in global scope), like
var
. - are hoisted: independently of where a function declaration is mentioned in its scope, it is always created at the beginning of the scope.
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…
- are block-scoped.
- don’t create properties on the global object.
- are not hoisted.
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) {
}
}
Further reading
- Using ECMAScript 6 today [an early draft of my book on ECMAScript 6]
- Destructuring and parameter handling in ECMAScript 6
Comments
Post a Comment