Course: JavaScript

Progress (0%)

JavaScript Scopes

Chapter 12 41 mins

Learning outcomes:

  1. What are identifier scopes
  2. The global scope
  3. Local scopes using functions
  4. What is identifier shadowing
  5. Block scopes using let/const
  6. How name resolution works in JavaScript

Introduction

So far in this course, we've learnt a great deal of information regarding the different aspects of JavaScript, ranging from data types, control flow, to functions, to operators, and a lot more.

Still, however, one of the most fundamental ideas that'll allow us to confidently work with these concepts and prevent surprising outcomes and errors is that of scopes.

In this chapter, we shall understand what is meant by the term 'scope' and how it's used to reason about where given identifiers would be accessible in a program. We'll also see how exactly JavaScript resolves names and binds them to particular values.

Each language has its own definition and rules of scopes, but at the end of the day, any discussion of scopes in any language whatsoever boils down to a discussion about where which identifiers are accessible. This knowledge is key to being able to appropriately structure our programs and prevent unexpected errors from creeping into them.

What are scopes?

Let's start off by defining 'scope' in plain words:

The scope of an identifier describes its availability in the underlying program.

The scope is a means of figuring out where the identifier could be accessed in the program. Not every identifier is accessible everywhere in a script; hence, the idea of scope is crucial to understand in order to keep from accessing identifiers where they don't exist at all!

To help understand this definition better, consider defining a variable inside a function.

In JavaScript, such a variable is only capable of being accessed inside the function, not anywhere outside it. We say that a variable defined inside a function has a local scope (more on this later below), which is confined to that function.

Now before we move on, it's worthwhile mentioning an important thing here.

The term 'scope' used in different ways

Some people refer to scope as a characteristic of identifiers. For instance, in the sentence "the variable x has a global scope," we're referring to the scope as a characteristic of the variable x, which turns out to be global in this case.

In contrary to this, some people just use the term 'scope' in a standalone manner. For instance, in the sentence "this is the global scope,", we aren't really referring to any particular identifier; rather, the term 'scope' is used to denote the whole set of identifiers that have a global scope.

While this latter approach isn't entirely incorrect, it's better to use distinct terminology to differentiate between these varied concepts.

Following this notion, we'll try to keep things square and use the term 'scope' to mean just one thing — a characteristic of an identifier. The other aspect would be referred to using the term 'environment'.

So, instead of saying something like "this is the global scope," we'll rather say "this is the global environment."

Resuming the discussion, JavaScript defines four different scopes for identifiers, depending on where and how they're defined:

  • Global scope
  • Local scope
  • Block scope (introduced with ES6)
  • Module scope (also introduced with ES6)

Module scope is tied to the idea of modules in JavaScript. We won't be covering modules in this course and, likewise, we'd leave off exploring the module scope in this chapter.

Let's explore the other kinds in detail.

Global scope

We'll start off with the very first kind, that is the global scope.

An identifier with a global scope is accessible throughout the entire program.

The word 'global' means 'relating to the whole of something' and that's exactly what it represents in the term 'global scope'. That is, an identifier with a global scope is available in the entire program.

Such an identifier is sometimes also referred to as a globally-scoped identifier or simply as a global identifier.

An identifier becomes global if it's not declared inside any function, i.e. if it's declared at the top level of the script. (There are certain exceptions, however, as we shall see below.)

Let's take a look at some examples.

In the following code, both the variables x and greeting are global variables, as they're defined at the top level of the script:

var x = 10;
console.log(x);

var greeting = 'Hello World!';

Similarly, the function showGreeting() and the constant PI in the code below are both globally-scoped as well:

function showGreeting() {
   alert('Hello World!');
}

const PI = 3.142;

However, the variable a below is not global. That's simply because it's defined inside a function:

function f() {
   var a = 10; // This variable is not global.
}

You might be thinking that the reasoning is very simple, but that's not really the case.

For example, the variable x below is global but the variable y is NOT (actually, it's block-scoped; more on that later in this chapter):

if (true) {
   var x = 10; // Globally-scoped
   let y = 20; // Block-scoped
}

console.log(x);
console.log(y);
10
Uncaught ReferenceError: y is not defined at <file>:7:13

The variable x exists beyond the if block and so the first log executes successfully. But, since y is not accessible outside the if block, its access throws an error.

This behavior relates to the semantics of var vs. let (and const).

To learn more about let, consider reading the chapter JavaScript Variables. Similarly, to learn more about const, consider reading JavaScript Constants.

In particular, let and const declarations respect the block they are defined in; they are only accessible within that block. In contrast, var declarations don't have such limitations; they're only affected by functions.

Moving on, as stated before, identifiers with a global scope are accessible everywhere in a program. They can be accessed by other functions, by other constructs, and even by other <script>s.

The following code demonstrates this:

var x = 10;

console.log('x at the top level:', x);

if (true) {
   console.log('x inside if:', x);
}

function f() {
   console.log('x inside f():', x);
}
f();
x at the top level: 10 x inside if: 10 x inside f(): 10

The global variable x is accessed at the top level, then inside an if block, and then finally inside a function; in each location, it's clear that x is accessible.

The following code now demonstrates the accessibility of global variables across different <script>s. Imagine that the code below is placed inside the <body> tag:

<script>
   var x = 10;
</script>
<script>
   console.log(x);
</script>

When we load the underlying HTML page in the browser, here's the output we get:

10

This is evident of the fact that global variables are accessible across different <script> tags.

It's important to note in the example above that variable hoisting doesn't happen across <script>s. That is, if we reverse the statements in the <script> tags above, the console.log(x) statement in the first script would then throw an error since x won't be accessible in the first script.
Some other languages, like PHP, require special constructs to access global variables inside functions. Fortunately, we don't need to worry about all this in JavaScript.

Undeclared global variables

If an undeclared variable is assigned a value in JavaScript, then regardless of wherever that assignment happens, the variable will be globally-scoped.

Here's an example:

function f() {
   undeclaredVar = true;
}

f();
console.log(undeclaredVar);
true

When f() is executed, an undeclared variable undeclaredVar is assigned a value. This results in the creation of a global variable with that name and value. That this variable is global is confirmed by the console log statement outside the function f().

In strict mode, such statements would throw an error and rightly so — it should be an error to assign to an undeclared variable.

But in case you're not in strict mode and you want to quickly define a global variable, let's say, from within a function, this is a handy shortcut to use.

Local scope

Next in line we have local scope.

An identifier declared inside a function has a local scope, i.e. it's only accessible inside that function.

Local scope is sometimes also referred to as function scope. An identifier with a local scope is also referred to as a locally-scoped identifier or simply as a local identifier.

Locally-scoped identifiers have particular significance in programming. They help us prevent unnecessarily polluting the global environment with names that ideally shouldn't be there.

Let's consider some examples of locally-scoped identifiers.

In the code below, the variable x has a local scope. More specifically, it's locally-scoped to the function foo():

function foo() {
   var x = 10;
}

This means that x won't be accessible outside the function foo() as is confirmed by the code below:

function foo() {
   var x = 10;
}

foo();
console.log(x); // x is not available here.
Uncaught ReferenceError: x is not defined at <file>:6:13

By virtue of local environments, which are independent of one another, we can have identically-named identifiers in different functions.

That's because each set of identifiers in each function is scoped to that particular function and doesn't interfere with the identically-named set of identifiers of another function.

In the code below, we have two functions, foo() and bar(), with two different variables that turn out to have the exact same name, x:

function foo() {
   var x = 10; // A different x
   console.log(x);
}

function bar() {
   var x = 20; // A different x
   console.log(x);
}

Once again, it's important to remember that the local variable x of foo() is different from the variable x of bar(). They only have the same name, NOT that they refer to the same variable in memory.

Moving on, since a local identifier is accessible inside the function where it's defined, even if we have a nested function defined inside that function, this nested function would also be able to access the local identifier.

In the following code, we define a local variable x in foo() and then a function bar() which accesses this local variable. Lastly, we call this function in foo():

function foo() {
   var x = 10;

   function bar() {
      console.log(x);
   }
   bar();
}

foo();
10

As as the console output confirms, the variable x is indeed available in bar().

This is simply because bar() is defined inside foo(), and so technically our access of x in bar() is lexically still accessing x from inside foo().

If bar() was defined outside of foo(), this would cease to be the case:

function bar() {
   console.log(x);
}

function foo() {
   var x = 10;
   bar();
}

foo();
Uncaught ReferenceError: x is not defined at <file>:2:16

It's not difficult to reason why this code fails: x can only be accessed within foo() in the source code; since bar() is not defined inside foo(), it can't access the x of foo().

Lexical vs. dynamic scopes

The behavior of JavaScript shown above, whereby calling an outsider function bar() inside foo() and still not allowing access to the local variable x of foo(), is consistent with the behavior of many other mainstream languages.

We say that JavaScript uses lexical scopes, sometimes also referred to as static scopes.

This simply means that the places where a given identifier is accessible is determined right from reading the source code (which is what 'lexical' means here), and that they don't change (which is what 'static' means).

So in the code above, when we read the definition of foo(), and owing to the fact that JavaScript has lexical scopes, we can clearly reason that x can only be accessed within foo(), NOT outside it in bar().

The complement of this is dynamic scopes, whereby the places where a given identifier is accessible change as the program proceeds.

Had JavaScript supported dynamic scopes, then the code above where bar() isn't defined inside foo() would've completed without any errors.

There are only a few languages that support dynamic scopes. Some examples are Bash and LaTeX.

Newbies often get scared by the term 'lexical' in discussions on the scoping rules of JavaScript and other languages, in general, perhaps because resources don't explain it in simply words. The reality is that it's actually a superbly simple idea.

Before proceeding forward, there is a paramount idea to clarify at this stage regarding local scopes.

We'll start with a very basic question: What is the scope of the variable x inside f() in the following code? Local or global?

var x;

function f() {
   x = 10;
   console.log(x);
}

f();
console.log(x);

Well, one might think that x inside f() is locally-scoped but that's NOT the case; instead, x is a global variable which is just assigned a value in the function f().

10 10

But why is this so?

That's simply because the declaration of x (i.e. var x) is not inside the function f(); it's in the top level of the script.

Always remember that the scope of a given identifier is governed by its declaration, NOT by where the identifier is assigned a value.

However, if our code was as follows:

var x = 'global';

function f() {
   var x = 'local';
   console.log(x);
}
f();

console.log(x);
local global

then the x referred to inside f() would be locally-scoped to f(), and outside f() we'd also have a global variable x.

Identifier shadowing (a.k.a. identifier masking)

The example above demonstrates another vital concept related to the scoping of identifiers — identifier shadowing, or identifier masking.

If we are specifically talking about variables, which is usually the case, then we call it as variable shadowing, or variable masking.

Identifier shadowing refers to when an identifier with a more localized scope effectively shadows a similarly-named identifier with a broader scope.

Notice how the global variable x in the code above is shadowed inside the function f() which has its own local x. Assigning to or reading x inside f() would apply to this local variable x, NOT to the global one.

Therefore, if we need to work with a particular global identifier inside a function, it's desirable to keep ourselves from naming any local identifier the same as the global identifier, and so prevent the global identifier from being shadowed.

Some resources even refer to this concept as name shadowing, or name masking.

Block scope

As we already know from the previous chapters, ECMAScript 6 introduced new ways of defining variables, via let, and of defining constants, via const, in JavaScript.

And with this, it also put forward a new kind of scope into the language: block scopes.

An identifier declared using let/const inside a block is said to have a block scope, i.e. it's accessible only inside the block.

A block here simply refers to a block statement, denoted via a pair of curly braces ({}).

An identifier with a block scope is referred to as a block-scoped identifier, or sometimes simply as a block identifier.

According to the creator of JavaScript, Brendan Eich, in his tweet reply from back in 2013:

10 days did not leave time for block scope. Also many "scripting languages" of that mid-90s era had few scopes & grew more later.

Many languages of the era, such as C, C++, Java, supported block-scoped identifiers, i.e. if they were defined inside a block of code, denoted via curly braces ({}), then they'd only be accessible within that block.

However, JavaScript didn't support block scopes from its beginning because of the added complexity of implementing them and the lack of time for doing such work. There was only one way of declaring variables, using var, and it did NOT exhibit block-scoped semantics.

The following code demonstrates what this means:

if (true) {
   var x = 10;
}

console.log(x);
10

The variable x is conditionally defined inside the if block and then accessed outside of that block. Since var doesn't follow block-scoping, the code works absolutely fine.

Making the existing var keyword to exhibit block-scoping would've been a backward-incompatible change and so, likewise, this never happened. Almost 20 years after the language's inception, with ES6, a new way was finally introduced of creating variables, using let (and even of creating constants using const)

This new way of variable declaration was chosen to exhibit the long-awaited block-scoping semantics.

Consider the following rewrite of the code above:

if (true) {
   let x = 10;
}

console.log(x);
Uncaught ReferenceError: x is not defined at <file>:5:13

Since x is declared using let, which follows block-scoping semantics, it does NOT exist outside of the if block.

The same applies to const:

if (true) {
   const x = 10;
}

console.log(x);
Uncaught ReferenceError: x is not defined at <file>:5:13

There are a few benefits of block scoping in JavaScript as you might be able to realize yourself:

  • Encourages us to better structure our code by declaring identifiers where they should be accessible.
  • Prevents unnecessary hoisting of variables which in turns prevents from accessing variables prior to their declaration.
  • Can be used to solve problems in loops that otherwise require the usage of closures. We'll learn this in detail in the JavaScript Functions — Closures chapter.

Focusing on point number one here, if we really want the let example above to be such that we can access x outside of the if block, then we ought to declare the variable outside of the if block, as follows:

let x; if (true) { x = 10; } console.log(x);
10

This makes it crystal clear that we want the variable x to be accessible beyond the if block. And this is the better code structure we were talking about earlier.

Precisely speaking, we can even do this using var but the thing is that it doesn't enforce us to think this way. Programmers are really lazy people (in a good sense) and if they are given a shortcut to do something, chances are high that they'll use it.

Anyways, just like local variables shadow global variables, block-scoped variables can shadow non-block-scoped variables.

Consider the following code:

var x = 'Using var';

if (true) {
   let x = 'Using let';
   console.log(x);
}

console.log(x);
Using let Using var
We can even use const x inside the if block above without any errors.

Even though it works fine, there's absolutely no good reason to use this kind of an approach in actual code. It makes the code a lot less readable and prone to errors.

Name resolution in JavaScript

All modern-day programming languages, including JavaScript, have sophisticated rules to determine what value to resolve a given name with.

This determination is absolutely crucial for the correctness of the program; if a given name resolves with the correct value, our program could at least be expected to run correctly (obviously given that the rest of the logic is right).

But if the names get resolved incorrectly, the program would cease to work correctly and we'd only be left with warning and error messages to deal with.

In this section, we aim to unravel the simple approach that JavaScript uses to resolve a name with a given identifier. Let's begin.

First, let's state the general principle and then move on to consider some examples to help understand it better.

The general principle of name resolution

When resolving a name, JavaScript searches for it in the closest environment and then keeps on traversing to the enclosing parent environments until it finds a match or it reaches the global environment.

If the name exists in the global environment, its value is used for the name's resolution. Otherwise, it's known for sure that the name doesn't exist in any environment whatsoever and so a ReferenceError exception is thrown.

Python resolves names in the exact same way as JavaScript. In fact, it has fancy name for this approach: the LEGB rule which stands for 'Local, Enclosing, Global and Built-in.'

Resolving a name here refers to both kinds of contexts, i.e. a name in the 'get' context and a name in the 'assignment' context.

Let's now consider a handful of examples to comprehend this better.

Consider the following code:

var y = 10;

function f() {
   var x = 20;
   console.log(x, y);
}

The point of concern is line 5 where we refer to a variable x and another variable y.

Let's resolve x.

First, search is made for x in the local environment of f(). Because x is indeed found here, the name x in line 5 is resolved with the value of this local x of f(), that is 20.

Simple.

Now, let's resolve y.

Search is made for y in the local environment of f(). Because no match is found here, searching shifts to the enclosing environment of f(), which simply turns out to be the global environment. Here, y indeed exists, likewise, the reference to y gets resolved with the value 10.

Once again, pretty simple.

As mentioned before, name resolution isn't just limited to retrieving a particular identifier's value — it also applies to cases where we assign a value to the identifier.

For example, in the following code, we try to assign values to the same variables from the previous code:

var y = 10;

function f() {
   var x = 20;
   x = 1000;
   y = 2000;
}

The points of concern here are lines 5 and 6, where we assign values to the identifiers x and y.

  • x, as we know, resolves to the local variable x, and so the assignment applies to this local variable.
  • y, on the other hand, resolves to the global variable y, and so the assignment applies to the global variable.

If we slightly modify the code here, the meaning of y inside f() could change:

var y = 10;

function f() {
   var x = 20;
   x = 1000;

   var y;
   y = 2000; // Now this applies to a local variable.
}

Now, the name y inside f() no longer resolves to the global variable y; it instead resolves to the local variable x of f() and, likewise, the assignment in line 8 applies to this local variable. The global variable y would remain as it is.

Let's consider another example.

var b = 50;

function f() {
   var a = 10;

   function g() {
      console.log(a, b);
   }
   g();
}

What do you think would a and b in line 7 here resolve to? Well, let's see.

The name a is first searched for in the local environment of g(). Since, it's not found there, searching continues to the enclosing environment, which is the local environment of f(). Here, indeed a variable a is found and is, henceforth, used to resolve the name a inside g().

As for the name b, both the searches in the local environment of g() and the local environment of f() end up without a match. It's only in the global environment that a match for b is found and that's used to resolve the name b in g().

As you might've started to realize, name resolution isn't difficult business. Just start your way at the closest environment where a name is referenced and work your way upwards until a match for the name is seen.

Let's consider yet another example, this time a slightly complex one:

var a = 100;

function f() {
   function g() {
      console.log(a);
   }

   a = 60;
   g();
}

What do you think would the name a in line 5 resolve down here with?

Well, don't get tricked by the assignments here and there. Let's follow our resolution principle.

First, searching happens in the local environment of g() for an identifier named a. Nothing is found, so searching shifts to the enclosing environment of g(), which is the local environment of f(). Even here, nothing is found.

But wait. What about the a = 60 statement in line 8?

Well, recall our earlier statement that remember that the scope of an identifier is governed by its declaration only. In this case, a = 60 isn't a declaration; it's merely an assignment of a, which itself refers to a from the global environment.

Coming back to our resolution process, with nothing found in g() and then f(), searching moves to the global environment. Here, indeed a match is found for a, and that's what the a in line 5 is resolved with.

To be precise, the name a in g() above, in line 5, resolves with the value 60.

Moving on, keep in mind that as soon as a match for a name is found in a given environment, searching effectively ends right there even if there are more matches for that name upstream. We say that identifiers in inner environments shadow the values of identifiers in outer environments while we access their corresponding names in the inner environments.

We already explored this phenomenon earlier in this chapter — it's referred to as identifier shadowing (or variable shadowing, if we're specifically talking about variables).

In the following code, the local variable x of f() shadows the global x inside f():

var x = 'global';

function f() {
   var x = 'local';
   console.log('From f():', x);
}
f()

console.log('From outside:', x);
From f(): local From outside: global

Shadowing doesn't just apply to variables, even constants could be masked, as demonstrated below:

const x = 'global';

function f() {
   const x = 'local';
   console.log('From f():', x);
}
f()

console.log('From outside:', x);
From f(): local From outside: global

This code doesn't throw an error. One might argue that since an identically-named constant is redeclared inside the function f(), the code should throw an error.

But, credits to shadowing, the constant x declared inside f() is in a separate environment and so doesn't constitute a redeclaration of the global constant x.

"I created Codeguage to save you from falling into the same learning conundrums that I fell into."

— Bilal Adnan, Founder of Codeguage