HomepageExploring JavaScript (ES2024 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

13 Variables and assignment

These are JavaScript’s main ways of declaring 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.

13.1 let

Variables declared via let are mutable:

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

You can also declare and assign at the same time:

let i = 0;

13.2 const

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

const i = 0; // must initialize

assert.throws(
  () => { i = i + 1 },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

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

const obj = { prop: 0 };

// Allowed: changing properties of `obj`
obj.prop = obj.prop + 1;
assert.equal(obj.prop, 1);

// Not allowed: assigning to `obj`
assert.throws(
  () => { obj = {} },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

13.2.2 const and loops

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

const arr = ['hello', 'world'];
for (const elem of arr) {
  console.log(elem);
}

Output:

hello
world

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

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

13.3 Deciding between const and let

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

Icon “exercise”Exercise: const

exercises/variables-assignment/const_exrc.mjs

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

{ // // Scope A. Accessible: x
  const x = 0;
  assert.equal(x, 0);
  { // Scope B. Accessible: x, y
    const y = 1;
    assert.equal(x, 0);
    assert.equal(y, 1);
    { // Scope C. Accessible: x, y, z
      const z = 2;
      assert.equal(x, 0);
      assert.equal(y, 1);
      assert.equal(z, 2);
    }
  }
}
// Outside. Not accessible: x, y, z
assert.throws(
  () => console.log(x),
  {
    name: 'ReferenceError',
    message: 'x is not defined',
  }
);

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.

13.4.1 Shadowing variables

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

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

Icon “details”Why eval()?

eval() delays parsing (and therefore the SyntaxError), until the callback of assert.throws() is executed. If we didn’t use it, we’d already get an error when this code is parsed and assert.throws() wouldn’t even be executed.

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

const x = 1;
assert.equal(x, 1);
{
  const x = 2;
  assert.equal(x, 2);
}
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.

13.5 (Advanced)

All remaining sections are advanced.

13.6 Terminology: static vs. dynamic

These two adjectives describe phenomena in programming languages:

Let’s look at examples for these two terms.

13.6.1 Static phenomenon: scopes of variables

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

function f() {
  const x = 3;
  // ···
}

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

13.6.2 Dynamic phenomenon: function calls

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

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

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

13.7 Global variables and the global object

JavaScript’s variable scopes are nested. They form a tree:

The root is also called the global scope. In web browsers, the only location where one is directly in that scope is at the top level of a script. The variables of the global scope are called global variables and accessible everywhere. There are two kinds of global variables:

The following HTML fragment demonstrates globalThis and the two kinds of global variables.

<script>
  const declarativeVariable = 'd';
  var objectVariable = 'o';
</script>
<script>
  // All scripts share the same top-level scope:
  console.log(declarativeVariable); // 'd'
  console.log(objectVariable); // 'o'
  
  // Not all declarations create properties of the global object:
  console.log(globalThis.declarativeVariable); // undefined
  console.log(globalThis.objectVariable); // 'o'
</script>

Each ECMAScript module has its own scope. Therefore, variables that exist at the top level of a module are not global. Figure 13.1 illustrates how the various scopes are related.

Figure 13.1: The global scope is JavaScript’s outermost scope. It has two kinds of variables: object variables (managed via the global object) and normal declarative variables. Each ECMAScript module has its own scope which is contained in the global scope.

13.7.1 globalThis [ES2020]

The global variable globalThis is the new standard way of accessing the global object. It got its name from the fact that it has the same value as this in global scope (script scope, not module scope).

Icon “details”globalThis does not always directly point to the global object

For example, in browsers, there is an indirection. That indirection is normally not noticable, but it is there and can be observed.

13.7.1.1 Alternatives to globalThis

The following global variables let us access the global object on some platforms:

Main browser threadWeb WorkersNode.js
globalThis
window
self
global
13.7.1.2 Use cases for globalThis

The global object is now considered a mistake that JavaScript can’t get rid of, due to backward compatibility. It affects performance negatively and is generally confusing.

ECMAScript 6 introduced several features that make it easier to avoid the global object – for example:

It is usually better to access global object variables via variables and not via properties of globalThis. The former has always worked the same on all JavaScript platforms.

Tutorials on the web occasionally access global variables globVar via window.globVar. But the prefix “window.” is not necessary and I recommend to omit it:

window.encodeURIComponent(str); // no
encodeURIComponent(str); // yes

Therefore, there are relatively few use cases for globalThis – for example:

13.8 Declarations: scope and activation

These are two key aspects of declarations:

Table 13.1 summarizes how various declarations handle these aspects.

ScopeActivationDuplicatesGlobal prop.
constBlockdecl. (TDZ)
letBlockdecl. (TDZ)
functionBlock (*)start
classBlockdecl. (TDZ)
importModulesame as export
varFunctionstart, partially

Table 13.1: Aspects of declarations:

  • “Duplicates” describes if a declaration can be used twice with the same name (per scope).
  • “Global prop.” describes if a declaration adds a property to the global object, when it is executed in the global scope of a script.
  • TDZ means temporal dead zone (which is explained later).

(*) Function declarations are normally block-scoped, but function-scoped in sloppy mode.

import is described in “ECMAScript modules” (§29.5). The following sections describe the other constructs in more detail.

13.8.1 const and let: temporal dead zone

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

{
  console.log(x); // What happens here?
  const x;
}

Some possible approaches are:

  1. The name is resolved in the scope surrounding the current scope.
  2. You get undefined.
  3. There is an error.

Approach 1 was rejected because there is no precedent in the language for this approach. It would therefore not be intuitive to JavaScript programmers.

Approach 2 was rejected because then x wouldn’t be a constant – it would have different values before and after its declaration.

let uses the same approach 3 as const, 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:

The following code illustrates the temporal dead zone:

if (true) { // entering scope of `tmp`, TDZ starts
  // `tmp` is uninitialized:
  assert.throws(() => (tmp = 'abc'), ReferenceError);
  assert.throws(() => console.log(tmp), ReferenceError);

  let tmp; // TDZ ends
  assert.equal(tmp, undefined);
}

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

if (true) { // entering scope of `myVar`, TDZ starts
  const func = () => {
    console.log(myVar); // executed later
  };

  // We are within the TDZ:
  // Accessing `myVar` causes `ReferenceError`

  let myVar = 3; // TDZ ends
  func(); // OK, called outside TDZ
}

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.

13.8.2 Function declarations and early activation

Icon “reading”More information on functions

In this section, we are using functions – before we had a chance to learn them properly. Hopefully, everything still makes sense. Whenever it doesn’t, please see “Callable values” (§27).

A function declaration is always executed when entering its scope, regardless of where it is located within that scope. That enables you to call a function foo() before it is declared:

assert.equal(foo(), 123); // OK
function foo() { return 123; }

The early activation of foo() means that the previous code is equivalent to:

function foo() { return 123; }
assert.equal(foo(), 123);

If you declare a function via const or let, then it is not activated early. In the following example, you can only use bar() after its declaration.

assert.throws(
  () => bar(), // before declaration
  ReferenceError);

const bar = () => { return 123; };

assert.equal(bar(), 123); // after declaration 
13.8.2.1 Calling ahead without early activation

Even if a function g() is not activated early, it can be called by a preceding function f() (in the same scope) if we adhere to the following rule: f() must be invoked after the declaration of g().

const f = () => g();
const g = () => 123;

// We call f() after g() was declared:
assert.equal(f(), 123);

The functions of a module are usually invoked after its complete body is executed. Therefore, in modules, you rarely need to worry about the order of functions.

Lastly, note how early activation automatically keeps the aforementioned rule: when entering a scope, all function declarations are executed first, before any calls are made.

13.8.2.2 A pitfall of early activation

If you rely on early activation to call a function before its declaration, then you need to be careful that it doesn’t access data that isn’t activated early.

funcDecl();

const MY_STR = 'abc';
function funcDecl() {
  assert.throws(
    () => MY_STR,
    ReferenceError);
}

The problem goes away if you make the call to funcDecl() after the declaration of MY_STR.

13.8.2.3 The pros and cons of early activation

We have seen that early activation has a pitfall and that you can get most of its benefits without using it. Therefore, it is better to avoid early activation. But I don’t feel strongly about this and, as mentioned before, often use function declarations because I like their syntax.

13.8.3 Class declarations are not activated early

Even though they are similar to function declarations in some ways, class declarations are not activated early:

assert.throws(
  () => new MyClass(),
  ReferenceError);

class MyClass {}

assert.equal(new MyClass() instanceof MyClass, true);

Why is that? Consider the following class declaration:

class MyClass extends Object {}

The operand of extends is an expression. Therefore, you can do things like this:

const identity = x => x;
class MyClass extends identity(Object) {}

Evaluating such an expression must be done at the location where it is mentioned. Anything else would be confusing. That explains why class declarations are not activated early.

13.8.4 var: hoisting (partial early activation)

var is an older way of declaring variables that predates const and let (which are preferred now). Consider the following var declaration.

var x = 123;

This declaration has two parts:

The following code demonstrates the effects of var:

function f() {
  // Partial early activation:
  assert.equal(x, undefined);
  if (true) {
    var x = 123;
    // The assignment is executed in place:
    assert.equal(x, 123);
  }
  // Scope is function, not block:
  assert.equal(x, 123);
}

13.9 Closures

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

13.9.1 Bound variables vs. free variables

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

Consider the following code:

function func(x) {
  const y = 123;
  console.log(z);
}

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

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

function funcFactory(value) {
  return () => {
    return value;
  };
}

const func = funcFactory('abc');
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).

Icon “tip”All functions in JavaScript are closures

Static scoping is supported via closures in JavaScript. Therefore, every function is a closure.

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

function createInc(startValue) {
  return (step) => { // (A)
    startValue += step;
    return startValue;
  };
}
const inc = createInc(5);
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:

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

13.9.4 Use cases for closures

What are closures good for?