Please support this book: buy it or donate

10. Variables and assignment



These are JavaScript’s main ways of declaring variables:

  • let declares mutable variables.
  • const declares constants (immutable variables).
    Before ES6, there was also var. But it has several quirks, so it’s best to avoid it in modern JavaScript. You can read more about it in “Speaking JavaScript”.

10.1. let

Variables declared via let are mutable:

  1. let i;
  2. i = 0;
  3. i = i + 1;
  4. assert.equal(i, 1);

You can also declare and assign at the same time:

  1. let i = 0;

10.2. const

Variables declared via const are immutable. You must always initialize immediately:

  1. const i = 0; // must initialize
  2. assert.throws(
  3. () => { i = i + 1 },
  4. {
  5. name: 'TypeError',
  6. message: 'Assignment to constant variable.',
  7. }
  8. );

10.2.1. const and immutability

In JavaScript, const only means that the binding (the association between variable name and variable value) is immutable. The value itself may be mutable, like obj in the following example.

  1. const obj = { prop: 0 };
  2. // Allowed: changing properties of `obj`
  3. obj.prop = obj.prop + 1;
  4. assert.equal(obj.prop, 1);
  5. // Not allowed: assigning to `obj`
  6. assert.throws(
  7. () => { obj = {} },
  8. {
  9. name: 'TypeError',
  10. message: 'Assignment to constant variable.',
  11. }
  12. );

10.2.2. const and loops

You can use const with for-of loops, where a fresh binding is created for each iteration:

  1. const arr = ['hello', 'world'];
  2. for (const elem of arr) {
  3. console.log(elem);
  4. }
  5. // Output:
  6. // 'hello'
  7. // 'world'

In plain for loops, you must use let, however:

  1. const arr = ['hello', 'world'];
  2. for (let i=0; i<arr.length; i++) {
  3. const elem = arr[i];
  4. console.log(elem);
  5. }

10.3. Deciding between let and const

I recommend the following rules to decide between let and const:

  • const indicates an immutable binding and that a variable never changes its value. Prefer it.
  • let indicates that the value of a variable changes. Use it only when you can’t use const.

10.4. The scope of a variable

The scope of a variable is the region of a program where it can be accessed. Consider the following code.

  1. { // // Scope A. Accessible: x
  2. const x = 0;
  3. assert.equal(x, 0);
  4. { // Scope B. Accessible: x, y
  5. const y = 1;
  6. assert.equal(x, 0);
  7. assert.equal(y, 1);
  8. { // Scope C. Accessible: x, y, z
  9. const z = 2;
  10. assert.equal(x, 0);
  11. assert.equal(y, 1);
  12. assert.equal(z, 2);
  13. }
  14. }
  15. }
  16. // Outside. Not accessible: x, y, z
  17. assert.throws(
  18. () => console.log(x),
  19. {
  20. name: 'ReferenceError',
  21. message: 'x is not defined',
  22. }
  23. );
  • Scope A is the (direct) scope of x.
  • Scopes B and C are inner scopes of scope A.
  • Scope A is an outer scope of scope B and scope C.
    Each variable is accessible in its direct scope and all scopes nested within that scope.

The variables declared via const and let are called block-scoped, because their scopes are always the innermost surrounding blocks.

10.4.1. Shadowing and blocks

You can’t declare the same variable twice at the same level:

  1. assert.throws(
  2. () => {
  3. eval('let x = 1; let x = 2;');
  4. },
  5. {
  6. name: 'SyntaxError',
  7. message: "Identifier 'x' has already been declared",
  8. });

You can, however, nest a block and use the same variable name x that you used outside the block:

  1. const x = 1;
  2. assert.equal(x, 1);
  3. {
  4. const x = 2;
  5. assert.equal(x, 2);
  6. }
  7. assert.equal(x, 1);

Inside the block, the inner x is the only accessible variable with that name. The inner x is said to shadow the outer x. Once you leave the block, you can access the old value again.

10.5. (Advanced)

All remaining sections are advanced.

10.6. Terminology: static vs. dynamic

These two adjectives describe phenomena in programming languages:

  • Static means that something is related to source code and can be determined without executing code.
  • Dynamic means at runtime.
    Let’s look at examples for these two terms.

10.6.1. Static phenomenon: scopes of variables

Variable scopes are a static phenomenon. Consider the following code:

  1. function f() {
  2. const x = 3;
  3. // ···
  4. }

x is statically (or lexically) scoped. That is, its scope is fixed and doesn’t change at runtime.

Variable scopes form a static tree (via static nesting).

10.6.2. Dynamic phenomenon: function calls

Function calls are a dynamic phenomenon. Consider the following code:

  1. function g(x) {}
  2. function h(y) {
  3. if (Math.random()) g(y); // (A)
  4. }

Whether or not the function call in line A happens, can only be decided at runtime.

Function calls form a dynamic tree (via dynamic calls).

10.7. Temporal dead zone: preventing access to a variable before its declaration

For JavaScript, TC39 needed to decide what happens if you access a variable in its direct scope, before its declaration:

  1. {
  2. console.log(x); // What happens here?
  3. let x;
  4. }

Some possible approaches are:

  • The name is resolved in the scope surrounding the current scope.
  • If you read, you get undefined. You can also already write to the variable. (That’s how var works.)
  • There is an error.
    TC39 chose (3) for const and let, because you likely made a mistake, if you use a variable name that is declared later in the same scope. (2) would not work for const (where each variable should always only have the value that it is initialized with). It was therefore also rejected for let, so that both work similarly and it’s easy to switch between them.

The time between entering the scope of a variable and executing its declaration is called the temporal dead zone (TDZ) of that variable:

  • During this time, the variable is considered to be uninitialized (as if that were a special value it has).
  • If you access an unitialized variable, you get a ReferenceError.
  • Once you reach a variable declaration, the variable is set to either the value of the initializer (specified via the assignment symbol) or undefined – if there is no initializer.
    The following code illustrates the temporal dead zone:
  1. if (true) { // entering scope of `tmp`, TDZ starts
  2. // `tmp` is uninitialized:
  3. assert.throws(() => (tmp = 'abc'), ReferenceError);
  4. assert.throws(() => console.log(tmp), ReferenceError);
  5. let tmp; // TDZ ends
  6. assert.equal(tmp, undefined);
  7. }

The next example shows that the temporal dead zone is truly temporal (related to time):

  1. if (true) { // entering scope of `myVar`, TDZ starts
  2. const func = () => {
  3. console.log(myVar); // executed later
  4. };
  5. // We are within the TDZ:
  6. // Accessing `myVar` causes `ReferenceError`
  7. let myVar = 3; // TDZ ends
  8. func(); // OK, called outside TDZ
  9. }

Even though func() is located before the declaration of myVar and uses that variable, we can call func(). But we have to wait until the temporal dead zone of myVar is over.

10.8. Hoisting

Hoisting means that a construct is moved to the beginning of its scope, regardless of where it is located in that scope:

  1. assert.equal(func(), 123); // Works!
  2. function func() {
  3. return 123;
  4. }

You can use func() before its declaration, because, internally, it is hoisted. That is, the previous code is actually executed like this:

  1. function func() {
  2. return 123;
  3. }
  4. assert.equal(func(), 123);

The temporal dead zone can be viewed as a form of hoisting, because the declaration affects what happens at the beginning of its scope.

10.9. Global variables

A variable is global if it is declared in the top-level scope. Every nested scope can access such a variable. In JavaScript, there are multiple layers of global scopes (Fig. 5):

  • The outermost global scope is special: its variables can be accessed via the properties of an object, the so-called global object. The global object is referred to by window and self in browsers. Variables in this scope are created via:

    • Properties of the global object
    • var and function at the top level of a script. (Scripts are supported by browsers. They are simple pieces of code and precursors to modules. Consult the chapter on modules for details.)
  • Nested in that scope is the global scope of scripts. Variables in this scope are created by let, const and class at the top level of a script.

  • Nested in that scope are the scopes of modules. Each module has its own global scope. Variables in that scope are created by declarations at the top level of the module.

Figure 5: JavaScript has multiple global scopes.

Figure 5: JavaScript has multiple global scopes.

10.9.1. The global object

The global object lets you access the outermost global scope via an object. The two are always in sync:

  • If you create a variable in the outermost global scope, the global object gets a new property. If you change such a global variable, the property changes.
  • If you create or delete a property of the global object, the corresponding global variable is created or deleted. If you change a property of the global object, the corresponding global variable changes.
    The global object is available via special variables:

  • window: is the classic way of referring to the global object. But it only works in normal browser code, not in Node.js and not in Web Workers (processes running concurrently to normal browser code; consult the chapter on asynchronous programming for details).

  • self: is available everywhere in browsers, including in Web Workers. But it isn’t supported by Node.js.
  • global: is only available in Node.js.
    Let’s examine how self works:
  1. // At the top level of a script
  2. var myGlobalVariable = 123;
  3. assert.equal('myGlobalVariable' in self, true);
  4. delete self.myGlobalVariable;
  5. assert.throws(() => console.log(myGlobalVariable), ReferenceError);
  6. // Create a global variable anywhere:
  7. if (true) {
  8. self.anotherGlobalVariable = 'abc';
  9. }
  10. assert.equal(anotherGlobalVariable, 'abc');

10.9.2. Avoid the global object!

Brendan Eich called the global object one of his biggest regrets about JavaScript. It is best not to put variables into its scope:

  • In general, variables that are global to all scripts on a web page, risk name clashes.
  • Via the global object, you can create and delete global variables anywhere. Doing so makes code unpredictable, because it’s normally not possible to make this kind of change in nested scopes.
    You occasionally see window.globalVariable in tutorials on the web, but the prefix “window.” is not necessary. I prefer to omit it:
  1. window.encodeURIComponent(str); // no
  2. encodeURIComponent(str); // yes

10.10. Closures

Before we can explore closures, we need to learn about bound variables and free variables.

10.10.1. Bound variables vs. free variables

Per scope, there is a set of variables that are mentioned. Among these variables we distinguish:

  • Bound variables are declared within the scope. They are parameters and local variables.
  • Free variables are declared externally. They are also called non-local variables.
    Consider the following code:
  1. function func(x) {
  2. const y = 123;
  3. console.log(z);
  4. }

In the body of func(), x and y are bound variables. z is a free variable.

10.10.2. What is a closure?

What is a closure, then?

A closure is a function plus a connection to the variables that exist at its “birth place”.

What is the point of keeping this connection? It provides the values for the free variables of the function. For example:

  1. function funcFactory(value) {
  2. return () => {
  3. return value;
  4. };
  5. }
  6. const func = funcFactory('abc');
  7. assert.equal(func(), 'abc'); // (A)

funcFactory returns a closure that is assigned to func. Because func has the connection to the variables at its birth place, it can still access the free variable value when it is called in line A (even though it “escaped” its scope).

10.10.3. Example: A factory for incrementors

The following function returns incrementors (a name that I just made up). An incrementor is a function that internally stores a number. When it is called, it updates that number by adding the argument to it and returns the new value.

  1. function createInc(startValue) {
  2. return (step) => { // (A)
  3. startValue += step;
  4. return startValue;
  5. };
  6. }
  7. const inc = createInc(5);
  8. assert.equal(inc(2), 7);

We can see that the function created in line A keeps its internal number in the free variable startValue. This time, we don’t just read from the birth scope, we use it to store data that we change and that persists across function calls.

We can create more storage slots in the birth scope, via local variables:

  1. function createInc(startValue) {
  2. let index = -1;
  3. return (step) => {
  4. startValue += step;
  5. index++;
  6. return [index, startValue];
  7. };
  8. }
  9. const inc = createInc(5);
  10. assert.deepEqual(inc(2), [0, 7]);
  11. assert.deepEqual(inc(2), [1, 9]);
  12. assert.deepEqual(inc(2), [2, 11]);

10.10.4. Use cases for closures

What are closures good for?

  • For starters, they are simply an implementation of static scoping. As such, they provide context data for callbacks.

  • They can also be used by functions to store state that persists across function calls. createInc() is an example of that.

  • And they can provide private data for object (produced via literals or classes). The details of how that works are explained in “Exploring ES6”.

10.11. Summary: ways of declaring variables

Table 1: These are all the ways in which you declare variables in JavaScript.
HoistingScopeScript scope is global object?
varDeclaration onlyFunction
letTemporal dead zoneBlock
constTemporal dead zoneBlock
functionEverythingBlock
classNoBlock
importEverythingModule

Tbl. 1 lists all ways in which you can declare variables in JavaScript: var, let, const, function, class and import.

10.12. Further reading

For more information on how variables are handled under the hood, consult the chapter “Variable environments”.