JavaScript Weekly: All About Scope

An Introduction to JavaScript Scoping Rules

Photo by Arnaud Papa on Unsplash

Welcome to the JavaScript Weekly! This week, our discussion is all about scope — one of the most fundamental parts of the JavaScript we know and love. Scope defines what is visible to whom, and where — with the what being some variable, the where being some point of execution in your program, and the whom being your friendly neighborhood functions. Without scope, your programs would be a mess of overlapping values and unpredictable outcomes — and if there is one thing programmers hate, it’s unpredictable outcomes! Thankfully, scope is there to help us keep our code clean, effective, and readable. So, without further ado, let’s dive in!


What is Scope?

In a non-technical context, scope means “the boundaries that define the subject or area with which something is concerned.” This turns out to be a pretty good way of thinking of JavaScript scope as well. Scope rules define the boundaries that restrict how individual pieces of code can interact with one another.

The first thing to know is that each piece of code does not exist in its own little walled garden, completely ignorant of what is outside of it. Scope boundaries are nested within one another, meaning that you can build inner sets of boundaries that are walled off from one another, but share access to a common outer boundary. The outermost boundary, which is at the top-level of your code, is known as global scope, and is accessible everywhere throughout your program. One level down are individual plots of local scope, which wall off their own code from other plots but still have access to code at the global level.

I like to think of scope like an apartment building. The building has some common areas that all residents can access (global scope), and then it has individual apartments that are only accessible to specific residents (local scope). And the nesting doesn’t end there. In addition to a particular apartment’s common areas, like the kitchen and living room, there are also individual bedrooms that only certain individuals can access. The result is a set of nested scopes that might look something like this:

The JS Scope Apartments

To take the analogy one step further, let’s throw variables into the mix. Imagine there is a computer in the building. Which of the building residents is allowed to use it? The answer depends on where the computer is “located” (that is, where in the code it was defined). In one case, it might be in the apartment building’s lobby (global scope) and is therefore accessible to all building residents. In another case, it might be in the living room of a specific apartment, in which case only people living in that apartment can use it (outer local scope). In yet another case it might be located in a specific bedroom, where it can only be accessed by the person living in that room (inner local scope).

So far our mental model says that scope defines a set of “boundaries” that restrict how individual pieces of JavaScript code can or cannot access other pieces. These boundaries can also be nested, such that code within a nested inner boundary can still access code in higher-level outer boundaries. But where do these boundaries come from? Are there different types? And, how do you make them? Let’s find out!


Defining Scope

Like most (though not all) languages, JavaScript uses lexical scoping, which is defined at author-time. This means that you can determine the current scope of the program, at any given point in execution, purely by looking at the source code. In languages that use dynamic scoping, scope is determined by the call stack and can change depending on how a given function is executed. Technically, there are ways to force JavaScript to generate dynamic scope, but they are generally discouraged and we won’t delve into them here.

There are a few ways to generate scope in JavaScript, the most common of which is function scope. Each function has its own local scope and code inside the function block must obey relevant scoping rules. This means that: a) code inside the function may access variables defined in global or higher-nested scope, but not variables defined in lower-nested scope; and, b) variables defined inside the function may not be accessed from global or higher-nested scope, but may be accessed by lower-nested scope.

Going back to our apartment building, let’s look at an example:

Here we have four different variables with the name computer, each of which is defined in a different scope. By looking at the source code we can draw conclusions about each variable, which will in turn dictate the output of our program. Here is a rundown of our various computers:

  • Line 1: Our first computer variable is defined at the top-level of our program (global scope) and has the value "hpDesktop". Think of this computer as being in the lobby of our apartment building. Since it is defined in global scope it is accessible throughout the program, meaning that any function defined in the program could use it.
  • Line 4: Our next computer variable is defined inside a function called apartmentOne and has the value "iMac". Since functions create their own local scope, this variable is only accessible by other code defined inside the same function. Imagine this computer to be in apartmentOne’s living room — any resident of that apartment can access and use it if needed.
  • Lines 7 & 12: Our final two computer variables are defined inside functions bedroomOne and bedroomTwo and have the values "macbookPro" and "chromeBook" respectively. Each of these bedroom functions are themselves defined inside the function scope of apartmentOne. However, they make their own local scope, which is nested inside apartmentOne’s scope, meaning that the computer variables they define are only accessible by other code inside their respective scope. In our analogy, only the individual people living in those bedrooms can access those computers.

When the program runs, the apartmentOne function is called, which in turn calls each of its three bedroom functions. An apartmentTwo function is also called, along with its own bedroom function. Each bedroom function logs a message to the console describing which of the various computers its individual resident uses. The output to the console is as follows:

8: apartmentOne, bedroomOne uses computer: macbookPro
13: apartmentOne, bedroomTwo uses computer: chromeBook
17: apartmentOne, bedroomThree uses computer: iMac
27: apartmentTwo, bedroomOne uses computer: hpDesktop

Was that the output you expected? What happened? Let’s look at each of the bedroom functions and find out.

  • apartmentOne / bedroomOne and apartmentOne / bedroomTwo each define their own computer variables in their respective local scope. When the calls to console.log() go to access a variable called computer, they find one in local scope and use it, logging values of macbookPro and chromeBook respectively.
  • apartmentOne / bedroomThree is a bit trickier. It does not define its own computer variable in local scope, so when the call to console.log() goes to look for a variable called computer, it has to reach up into the scope of apartmentOne, thus outputting the value "iMac".
  • In the case of apartmentTwo / bedroomOne neither bedroomOne nor apartmentTwo has a variable called computer, so the call to console.log() instead goes all the way up to global scope, where it finds a variable called computer and outputs the value of "hpDesktop".

The interesting thing to note here is that each function call checked for a computer variable in its local scope before moving up to look in higher levels of scope. Furthermore, the various variables, despite sharing the same name, did not overwrite or otherwise conflict with one another. As far as each function is concerned, there is only one variable called comptuer, and scope dictates where, and how, a particular function will look for that variable. Think of the residents of each bedroom — they use the computer that is closest to them and are unconcerned with the existence of others. A person in one bedroom can’t go into another person’s bedroom and use their computer (in JavaScript roommates don’t like sharing), in fact, they don’t even know that the other computer exists! They would sooner go all the way out to the lobby to use a computer than into a roommate’s bedroom.

Aside from functions, the other primary type of scope is block scope, which is defined between curly braces ( {...} ), for example, any place you write an if...else block. Unlike function scope though, not all kinds of variables respect block scope. Variables you define with the var keyword are not block-scoped, and are thus accessible outside the block. However, variables you define with the ES6 keywords let and const are block-scoped, and respect scoping rules. Let’s look at an example:

// output:
6: Inside the block…
7: The fruit is: banana
8: The vegetable is: carrot
9: The spice is: paprika
12: Outside the block…
13: The fruit is: banana
14: * Uncaught ReferenceError: vegetable is not defined
15: * Uncaught ReferenceError: spice is not defined

In this example, we define three variables, fruit, vegetable, and spice inside a block. We make two attempts at logging their values, one from inside the block and one from outside; however, only the variable fruit is accessible outside the block, whereas the other two throw uncaught reference errors. The reason is that only fruit was defined using the var keyword, which is not block-scoped, whereas vegetable and spice were defined with let and const respectively, and thus obey scoping rules.


Closure

Before wrapping up our review of scope, let’s go over an important related concept known as scope closures. This could be an entire blog post on its own, and it is the basis of many JavaScript design patterns, but let’s just focus on one core concept: functions retain access to variables defined by their lexical scope, even when invoked from outside that scope. In practice, this means that the scope used by a function is the scope at function definition, not the scope at function invocation. Here is a quick example:

In this example, we have a function eatSandwich, and a variable sandwich, both of which are defined in global scope. eatSandwich accesses the variable sandwich and logs a message to the console. Also defined in global scope we have a function lunch, which defines its own variable sandwich in its own local scope and then invokes eatSandwich, which it can access from global scope. When the code runs it logs the following to the console:

4: Now eating ham and cheese!

The function eatSandwich only cares about the variable sandwich that is within its scope at definition time. eatSandwich closes over this value at definition and retains access to it even when called from another scope. Despite being invoked from inside lunch, eatSandwich doesn’t care about the sandwich variable on line 8 because it was not in scope at the time eatSandwich was defined. Think of it this way — eatSandwich could not have predicted that it would one day be called from inside lunch and it therefore has no reason to look inside lunch for a variable called sandwich. This is the nature of lexical scoping. Only in a dynamically-scoped language would eatSandwich bother to look through the call-stack for some necessary variable — and as we know, JavaScript does not use dynamic scoping.


TL;DR

JavaScript relies on scope to determine which pieces of code are accessible at any given point in program execution. Scope can be thought of as a set of boundaries around particular pieces of code in a program. JavaScript uses lexical scoping, meaning that scope is defined by the source code at author-time, rather than changing dynamically at execution-time. Scope exists at the global level (the top-level of your code), and at the local level (inside individual functions, and in some cases, blocks). Scope is also nesting in nature, meaning that code at lower levels of nested scope can access variables that are defined in higher levels, but not vice versa. Individual functions also exhibit scoping closure behavior, meaning that they close over variables that are within scope at the time a function is defined. Closures allow functions to retain access to a given variable even if invoked from a different scope.


That’s it for this week’s discussion of scope! I hope you learned something new about this fundamental concept in JavaScript programming. See you next week!