Please support this book: buy it (PDF, EPUB, MOBI) or donate

9. Variables and scoping

9.1 Overview

ES6 provides two new ways of declaring variables: let and const, which mostly replace the ES5 way of declaring variables, var.

9.1.1 let

let works similarly to var, but the variable it declares is block-scoped, it only exists within the current block. var is function-scoped.

In the following code, you can see that the let-declared variable tmp only exists inside the block that starts in line A:

  1. function order(x, y) {
  2. if (x > y) { // (A)
  3. let tmp = x;
  4. x = y;
  5. y = tmp;
  6. }
  7. console.log(tmp===x); // ReferenceError: tmp is not defined
  8. return [x, y];
  9. }

9.1.2 const

const works like let, but the variable you declare must be immediately initialized, with a value that can’t be changed afterwards.

  1. const foo;
  2. // SyntaxError: missing = in const declaration
  3.  
  4. const bar = 123;
  5. bar = 456;
  6. // TypeError: `bar` is read-only

Since for-of creates one binding (storage space for a variable) per loop iteration, it is OK to const-declare the loop variable:

  1. for (const x of ['a', 'b']) {
  2. console.log(x);
  3. }
  4. // Output:
  5. // a
  6. // b

9.1.3 Ways of declaring variables

The following table gives an overview of six ways in which variables can be declared in ES6 (inspired by a table by kangax):

HoistingScopeCreates global properties
varDeclarationFunctionYes
letTemporal dead zoneBlockNo
constTemporal dead zoneBlockNo
functionCompleteBlockYes
classNoBlockNo
importCompleteModule-globalNo

9.2 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 const-declared variable tmp only exists inside the block of the if statement:

  1. function func() {
  2. if (true) {
  3. const tmp = 123;
  4. }
  5. console.log(tmp); // ReferenceError: tmp is not defined
  6. }

In contrast, var-declared variables are function-scoped:

  1. function func() {
  2. if (true) {
  3. var tmp = 123;
  4. }
  5. console.log(tmp); // 123
  6. }

Block scoping means that you can shadow variables within a function:

  1. function func() {
  2. const foo = 5;
  3. if (···) {
  4. const foo = 10; // shadows outer `foo`
  5. console.log(foo); // 10
  6. }
  7. console.log(foo); // 5
  8. }

9.3 const creates immutable variables

Variables created by let are mutable:

  1. let foo = 'abc';
  2. foo = 'def';
  3. console.log(foo); // def

Constants, variables created by const, are immutable – you can’t assign different values to them:

  1. const foo = 'abc';
  2. foo = 'def'; // TypeError

9.3.1 Pitfall: const does not make the value immutable

const only means that a variable always has the same value, but it does not mean that the value itself is or becomes immutable. For example, obj is a constant, but the value it points to is mutable – we can add a property to it:

  1. const obj = {};
  2. obj.prop = 123;
  3. console.log(obj.prop); // 123

We cannot, however, assign a different value to obj:

  1. obj = {}; // TypeError

If you want the value of obj to be immutable, you have to take care of it, yourself. For example, by freezing it:

  1. const obj = Object.freeze({});
  2. obj.prop = 123; // TypeError
9.3.1.1 Pitfall: Object.freeze() is shallow

Keep in mind that Object.freeze() is shallow, it only freezes the properties of its argument, not the objects stored in its properties. For example, the object obj is frozen:

  1. > const obj = Object.freeze({ foo: {} });
  2. > obj.bar = 123
  3. TypeError: Can't add property bar, object is not extensible
  4. > obj.foo = {}
  5. TypeError: Cannot assign to read only property 'foo' of #<Object>

But the object obj.foo is not.

  1. > obj.foo.qux = 'abc';
  2. > obj.foo.qux
  3. 'abc'

9.3.2 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:

  1. function logArgs(...args) {
  2. for (const [index, elem] of args.entries()) { // (A)
  3. const message = index + '. ' + elem; // (B)
  4. console.log(message);
  5. }
  6. }
  7. logArgs('Hello', 'everyone');
  8.  
  9. // Output:
  10. // 0. Hello
  11. // 1. everyone

There are two const declarations in this code, in line A and in line B. And during each loop iteration, their constants have different values.

9.4 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 compare the life cycles of var-declared variables (which don’t have TDZs) and let-declared variables (which have TDZs).

9.4.1 The life cycle of var-declared variables

var variables don’t have temporal dead zones. Their life cycle comprises the following steps:

  • When the scope (its surrounding function) of a var variable is entered, storage space (a 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 of the variable remains undefined.

9.4.2 The life cycle of let-declared variables

Variables declared via let have temporal dead zones and their life cycle looks like this:

  • When the scope (its surrounding block) of a let variable is entered, storage space (a binding) is created for it. The variable remains uninitialized.
  • Getting or setting an uninitialized variable 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 then 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.

9.4.3 Examples

Within a TDZ, an exception is thrown if a variable is got or set:

  1. let tmp = true;
  2. if (true) { // enter new scope, TDZ starts
  3. // Uninitialized binding for `tmp` is created
  4. console.log(tmp); // ReferenceError
  5.  
  6. let tmp; // TDZ ends, `tmp` is initialized with `undefined`
  7. console.log(tmp); // undefined
  8.  
  9. tmp = 123;
  10. console.log(tmp); // 123
  11. }
  12. console.log(tmp); // true

If there is an initializer then the TDZ ends after the initializer was evaluated and the result was assigned to the variable:

  1. let foo = console.log(foo); // ReferenceError

The following code demonstrates that the dead zone is really temporal (based on time) and not spatial (based on location):

  1. if (true) { // enter new scope, TDZ starts
  2. const func = function () {
  3. console.log(myVar); // OK!
  4. };
  5.  
  6. // Here we are within the TDZ and
  7. // accessing `myVar` would cause a `ReferenceError`
  8.  
  9. let myVar = 3; // TDZ ends
  10. func(); // called outside TDZ
  11. }

9.4.4 typeof throws a ReferenceError for a variable in the TDZ

If you access a variable in the temporal dead zone via typeof, you get an exception:

  1. if (true) {
  2. console.log(typeof foo); // ReferenceError (TDZ)
  3. console.log(typeof aVariableThatDoesntExist); // 'undefined'
  4. let foo;
  5. }

Why? The rationale is as follows: foo is not undeclared, it is uninitialized. You should be aware of its existence, but aren’t. Therefore, being warned seems desirable.

Furthermore, this kind of check is only useful for conditionally creating global variables. That is something that you don’t need to do in normal programs.

9.4.4.1 Conditionally creating variables

When it comes to conditionally creating variables, you have two options.

Option 1 – typeof and var:

  1. if (typeof someGlobal === 'undefined') {
  2. var someGlobal = { ··· };
  3. }

This option only works in global scope (and therefore not inside ES6 modules).

Option 2 – window:

  1. if (!('someGlobal' in window)) {
  2. window.someGlobal = { ··· };
  3. }

9.4.5 Why is there a temporal dead zone?

There are several reasons why const and let have temporal dead zones:

  • To catch programming errors: Being able to access a variable before its declaration is strange. If you do so, it is normally by accident and you should be warned about it.
  • For const: Making const work properly is difficult. Quoting Allen Wirfs-Brock: “TDZs … provide a rational semantics for const. There was significant technical discussion of that topic and TDZs emerged as the best solution.” let also has a temporal dead zone so that switching between let and const doesn’t change behavior in unexpected ways.
  • Future-proofing for guards: JavaScript may eventually have guards, a mechanism for enforcing at runtime that a variable has the correct value (think runtime type check). If the value of a variable is undefined before its declaration then that value may be in conflict with the guarantee given by its guard.

9.4.6 Further reading

Sources of this section:

9.5 let and const in loop heads

The following loops allow you to declare variables in their heads:

  • for
  • for-in
  • for-of To make a declaration, you can use either var, let or const. Each of them has a different effect, as I’ll explain next.

9.5.1 for loop

var-declaring a variable in the head of a for loop creates a single binding (storage space) for that variable:

  1. const arr = [];
  2. for (var i=0; i < 3; i++) {
  3. arr.push(() => i);
  4. }
  5. arr.map(x => x()); // [3,3,3]

Every i in the bodies of the three arrow functions refers to the same binding, which is why they all return the same value.

If you let-declare a variable, a new binding is created for each loop iteration:

  1. const arr = [];
  2. for (let i=0; i < 3; i++) {
  3. arr.push(() => i);
  4. }
  5. arr.map(x => x()); // [0,1,2]

This time, each i refers to the binding of one specific iteration and preserves the value that was current at that time. Therefore, each arrow function returns a different value.

const works like var, but you can’t change the initial value of a const-declared variable:

  1. // TypeError: Assignment to constant variable
  2. // (due to i++)
  3. for (const i=0; i<3; i++) {
  4. console.log(i);
  5. }

Getting a fresh binding for each iteration may seem strange at first, but it is very useful whenever you use loops to create functions that refer to loop variables, as explained in a later section.

9.5.2 for-of loop and for-in loop

In a for-of loop, var creates a single binding:

  1. const arr = [];
  2. for (var i of [0, 1, 2]) {
  3. arr.push(() => i);
  4. }
  5. arr.map(x => x()); // [2,2,2]

const creates one immutable binding per iteration:

  1. const arr = [];
  2. for (const i of [0, 1, 2]) {
  3. arr.push(() => i);
  4. }
  5. arr.map(x => x()); // [0,1,2]

let also creates one binding per iteration, but the bindings it creates are mutable.

The for-in loop works similarly to the for-of loop.

9.5.3 Why are per-iteration bindings useful?

The following is an HTML page that displays three links:

  • If you click on “yes”, it is translated to “ja”.
  • If you click on “no”, it is translated to “nein”.
  • If you click on “perhaps”, it is translated to “vielleicht”.
  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta charset="UTF-8">
  5. </head>
  6. <body>
  7. <div id="content"></div>
  8. <script>
  9. const entries = [
  10. ['yes', 'ja'],
  11. ['no', 'nein'],
  12. ['perhaps', 'vielleicht'],
  13. ];
  14. const content = document.getElementById('content');
  15. for (const [source, target] of entries) { // (A)
  16. content.insertAdjacentHTML('beforeend',
  17. `<div><a id="${source}" href="">${source}</a></div>`);
  18. document.getElementById(source).addEventListener(
  19. 'click', (event) => {
  20. event.preventDefault();
  21. alert(target); // (B)
  22. });
  23. }
  24. </script>
  25. </body>
  26. </html>

What is displayed depends on the variable target (line B). If we had used var instead of const in line A, there would be a single binding for the whole loop and target would have the value 'vielleicht', afterwards. Therefore, no matter what link you click on, you would always get the translation 'vielleicht'.

Thankfully, with const, we get one binding per loop iteration and the translations are displayed correctly.

9.6 Parameters as variables

9.6.1 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:

  1. function func(arg) {
  2. let arg; // static error: duplicate declaration of `arg`
  3. }

Doing the same inside a block shadows the parameter:

  1. function func(arg) {
  2. {
  3. let arg; // shadows parameter `arg`
  4. }
  5. }

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.

  1. function func(arg) {
  2. var arg; // does nothing
  3. }
  1. function func(arg) {
  2. {
  3. // We are still in same `var` scope as `arg`
  4. var arg; // does nothing
  5. }
  6. }

9.6.2 Parameter default values and the temporal dead zone

If parameters have default values, they are treated like a sequence of let statements and are subject to temporal dead zones:

  1. // OK: `y` accesses `x` after it has been declared
  2. function foo(x=1, y=x) {
  3. return [x, y];
  4. }
  5. foo(); // [1,1]
  6.  
  7. // Exception: `x` tries to access `y` within TDZ
  8. function bar(x=y, y=2) {
  9. return [x, y];
  10. }
  11. bar(); // ReferenceError

9.6.3 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:

  1. const foo = 'outer';
  2. function bar(func = x => foo) {
  3. const foo = 'inner';
  4. console.log(func()); // outer
  5. }
  6. bar();

9.7 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 makes sense 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 declarations
    • const declarations
    • Class declarations Note that the bodies of modules are not executed in global scope, only scripts are. Therefore, the environments for various variables form the following chain.

9. Variables and scoping - 图1

9.8 Function declarations and class declarations

Function declarations…

  • are block-scoped, like let.
  • create properties in 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:
  1. { // Enter a new scope
  2.  
  3. console.log(foo()); // OK, due to hoisting
  4. function foo() {
  5. return 'hello';
  6. }
  7. }

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.
  1. { // Enter a new scope
  2.  
  3. const identity = x => x;
  4.  
  5. // Here we are in the temporal dead zone of `MyClass`
  6. const inst = new MyClass(); // ReferenceError
  7.  
  8. // Note the expression in the `extends` clause
  9. class MyClass extends identity(Object) {
  10. }
  11. }

9.9 Coding style: const versus let versus var

I recommend to always use either let or const:

  • Prefer const. You can use it whenever a variable never changes its value. In other words: the variable should never be the left-hand side of an assignment or the operand of ++ or . Changing an object that a const variable refers to is allowed:
  1. const foo = {};
  2. foo.prop = 123; // OK

You can even use const in a for-of loop, because one (immutable) binding is created per loop iteration:

  1. for (const x of ['a', 'b']) {
  2. console.log(x);
  3. }
  4. // Output:
  5. // a
  6. // b

Inside the body of the for-of loop, x can’t be changed.

  • Otherwise, use let – when the initial value of a variable changes later on.
  1. let counter = 0; // initial value
  2. counter++; // change
  3.  
  4. let obj = {}; // initial value
  5. obj = { foo: 123 }; // change
  • Avoid var. If you follow these rules, var will only appear in legacy code, as a signal that careful refactoring is required.

var does one thing that let and const don’t: variables declared via it become properties of the global object. However, that’s generally not a good thing. You can achieve the same effect by assigning to window (in browsers) or global (in Node.js).

9.9.1 An alternative approach

An alternative to the just mentioned style rules is to use const only for things that are completely immutable (primitive values and frozen objects). Then we have two approaches:

  • Prefer const: const marks immutable bindings.
  • Prefer let: const marks immutable values. I lean slightly in favor of #1, but #2 is fine, too.