Free email newsletter: “ES.next News

2015-02-07

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 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.

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 declarations
    • const 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

  1. Using ECMAScript 6 today [an early draft of my book on ECMAScript 6]
  2. Destructuring and parameter handling in ECMAScript 6

No comments: