Scope

Variables in programming are used either to track state across a program's lifecycle (like a logged in or logged out state) or to store constant values that need to be used later (like an API key). Being able to access a variable, whether to give it value, redefine its value, or use its value, is fundamental to the lifecycle of an effective program. Much of what I'm writing about today was covered in my March blog post, var, let, and const, but today's post shifts the focus a little bit to the topic of scope.

Scope refers to a set of rules for how variables and their values get accessed throughout the lifecycle of a program. For example:

var ovenOn = false;
var ovenTemperature = 70;

function heatOven(targetTemperature) {
  if (ovenOn === false) {
    ovenOn = true;
    console.log("Oven has been turned on.");
  }

  while (ovenTemperature < targetTemperature) {
    ovenTemperature += 1;
  }

  console.log(`Oven has been heated to ${ovenTemperature}.`);
}

function turnOvenOff() {
  if (ovenOn === true) {
    ovenOn = false;
  }
  console.log("Oven has been turned off.");
}

heatOven(350);
// expected result:
// Oven has been turned on.
// Oven has been heated to 350.

turnOvenOff();
// expected result: Oven has been turned off.

In this little program, we're working with two variables: ovenOn and ovenTemperature. These are declared using the var keyword outside of any other functions, so they are said to belong to the global scope, making them (and their values) accessible throughout the program. When we later make references to ovenOn or ovenTemperature inside of function definitions, we can use and modify those variables.

Let's look at another example:

function logUserIn() {
  var loggedIn = true;
}

logUserIn();

if (loggedIn) {
  console.log("User is logged in."); // this code never runs
}
// expected result: ReferenceError: loggedIn is not defined

Here we have a function logUserIn(), and inside of that function block we declare a variable loggedIn and assign it the value of true. We then call the logUserIn() function and try to check the loggedIn variable value to log the message "User is logged in." if loggedIn is true. However, we get a ReferenceError that loggedIn is not defined. This happens because we declared the loggedIn variable inside of the logUserIn() function, but tried to access it outside of that function. The loggedIn variable is said to be at the logUserIn() function scope, and our access attempt would be outside of that function, in the global scope; so we are trying to access that variable outside of its scope.

Nested scopes are available in JavaScript:

function firstFunction(val1) {
  var val2 = val1 + 100;
  function secondFunction(val3) {
    console.log(val1, val2, val3);
    // expected result: 420, 520, 720
  }
  secondFunction(val2 + 200);
}

firstFunction(420);

In this example there are three levels of scope:

  1. The outermost scope, the global scope
  2. The scope of the first function, firstFunction()
  3. The scope of the next inner function inside of firstfunction(), secondFunction()

In the global scope the only identifier available is the firstfunction() function.

Within firstFunction()'s scope, we define the val1 identifier, which is implicitly defined as a parameter assignment, and given the value 420 when the function is called with 420 as its argument, the val2 identifier, explicitly declared using the var keyword and given the value of 100 plus the value of val1 (420), so 520, and the secondFunction() identifier.

Within secondFunction()'s scope, we define the val3 identifier, implicitly defined as a parameter assignment, and given the value of 720 when the function is called and val2 + 200 is given as its argument. However, because secondFunction() is nested within firstFunction() and firstfunction() is nested within the global scope, we can access all identifiers in that nesting chain within secondFunction(). This is what gives us all three values 420, 520, 720 when we run console.log(val1, val2, val3);.

We can also redefine variables from an outer scope in an inner scope:

function firstFunction(val1) {
  var val2 = val1 + 100;
  function secondFunction(val3) {
    val1 = 429;
    val2 = 666;
    console.log(val1, val2, val3);
    // expected result: 429, 666, 720
  }
  secondFunction(val2 + 200);
}

firstFunction(420);

This time around we redefine the values of val1 and val2 to 429 and 666 inside of secondFunction(), but those values are still 420 and 520 in the firstFunction() scope, so we still get 720 for val3. But now when we console.log(val1, val2); inside of secondfunction() the values within that scope are used (instead of the values of the outer scope). This behavior is called "shadowing". It's a powerful way that we can fine-tune the values of our variables across the life cycle of our program at different scopes where they may be needed.

Units of Scope

Now that we have a basic understanding of scope, let's dive into the four units of scope available in JavaScript: global scope, function scope, block scope, and module scope. We have seen examples of the first two already, but I'll focus on each unit individually and get into behavior specific to them.

Global Scope

If a variable has been declared outside of any other function or (for let and const variables) code block, it is said to be in the global scope. This is the top level of a program, and within a browser is called the window. Variables declared at the global scope using the var keyword can either be called directly or as properties on the global object:

var globalThing = "Howdy 123";

console.log(globalThing);
// expected result: "Howdy 123"
console.log(window.globalThing);
// expected result: "Howdy 123"

Identifiers at the global scope are available at all other scopes, since any other function or block scope is nested inside of the global scope:

var globalThing = "Howdy 123";

function firstFunction() {
  console.log(globalThing);
  // expected result: "Howdy 123"

  function secondFunction() {
    console.log(globalThing);
    // expected result: "Howdy 123"
  }
  secondFunction();
}

firstFunction();

A var variable at the global scope can be redeclared in a nested function. If it's just being redeclared, then the original value of the global identifier can still be accessed as a property on the global object:

var globalThing = "Howdy 123";

function firstFunction() {
  var globalThing = "Hey there 321";
  console.log(globalThing);
  // expected result: "Hey there 321"

  function secondFunction() {
    console.log(globalThing);
    // expected result: "Hey there 321"

    console.log(window.globalThing);
    // expected result: "Howdy 123"
  }
  secondFunction();
}

firstFunction();

When you're not working in strict mode, you can create variables by omitting the var, let, or const keyword, and just assigning that variable name a value. It will be created like any other var variable:

globalThing = "Howdy 123";

console.log(globalThing);
// expected result: "Howdy 123"
console.log(window.globalThing);
// expected result: "Howdy 123"

However, this is generally considered poor programming practice; if you are creating a variable, you should communicate intent to do so using one of the declaration keywords (and thereby determine how you want that variable to behave, i.e. as a var, let, or const variable). Because of this, strict mode will throw a ReferenceError if you attempt to do so:

"use strict";
globalThing = "Howdy 123";
// expected result: ReferenceError: globalThing is not defined

This helps to prevent accidental scope pollution if you have a typo:

"use strict";
var globalThing = "Howdy 123";
globalThingg = "Hey there 321";
// expected result: ReferenceError: globalThingg is not defined

The question of what should be in global scope vs. what shouldn't be goes well beyond mistyped variable names. A larger, related question here is: what information needs to be accessed across the entire program? There are two main problems with having all variables and their values available across the global scope:

  1. privilege: some information probably needs to be kept "secret" as it were, or at least should be contained only to where it's relevant so that it doesn't get overwritten or accessed when it shouldn't be;
  2. memory: if you have a bunch of data objects scattered throughout your global scope, it can start to take up a lot of memory. Ideally you would use the data only when you need it, and when you no longer need it, the JavaScript engine would safely carry that data off to garbage collection.

Fortunately for us, two other scope units exist that help to address these two concerns: function scope and block scope.

Function Scope

We've already seen some examples of function scoped variables, but to review, variables (and other functions) declared within a function are said to be function scoped, and are therefore available to access within that function:

function firstFunction() {
  var val = 420;
  console.log(val);
  // expected result: 420
}

firstFunction();

Variables declared within a function are available to functions that are also declared in that function, illustrating the concept of nested scope:

function firstFunction() {
  var val = 420;
  function secondFunction() {
    console.log(val);
    // expected result: 420
  }
  secondFunction();
}

firstFunction();

However, variables declared within a function are not available outside of that function, for example in the global scope:

function firstFunction() {
  var val = 420;
}

firstFunction();
console.log(val);
// expected result: ReferenceError: val is not defined

In this way, function scoped identifiers are one solution to the global scope pollution problem. We are able to keep variables and their data contained to the functionality where it's needed, and it doesn't bleed into the global scope.

However, what if we do want some data available at the global scope in order to get a little bit of work done, and then we don't want that data to stick around? Like we need some variables and functionality to run in order to initialize our program (like load a Google Analytics tracking script), but then once that functionality is done running, those variables and functions no longer need to take up space in the global scope? That is where Immediately Invoked Function Expressions (IIFEs) come in.

For a more thorough exploration into IIFEs, check out my blog post from May, What is an IIFE?. As it pertains to the topic of scope, IIFEs are a handy way of avoiding global namespace pollution.

Say we want to greet a user by name:

var userName = prompt('What is your name?'); // input "Joey"

function sayHello(name) {
  console.log(`Hi there ${name}!`);
}

sayHello(userName);
// expected result: "Hi there Joey!"
console.log(window.userName);
// expected result: "Joey"
console.log(sayHello);
// expected result: function sayHello(name) { ...etc.. }

In this example we've created a var variable, userName, and assigned it the value that a user inputs to the prompt, 'What is your name?'.

After that we define a function named sayHello(), which accepts a name parameter, and greets the user by name.

Below that, we run the function, passing it the userName variable as its argument.

Finally, we console.log(window.userName) and sayHello and see that we have polluted our window scope with the userName variable and the sayHello() function.

If we're only going to run this code once and never again, it doesn't make a lot of sense to have the window carrying around a userName variable or the sayHello() function in its global scope for the entire time our application runs. Instead, we can use an IIFE and not pollute the global scope:

(function sayHello() {
  var userName = prompt('What is your name?');
  console.log(`Hi there ${userName}!`);
})();
console.log(window.userName);
// expected result: undefined
console.log(sayHello);
// expected result: ReferenceError: sayHello is not defined

Because var variables are scoped to the function that they're declared in, the userName variable only exists within the sayHello() function, which only exists within the IIFE. This is key to understanding the benefits of IIFEs: that before the introduction of let and const variables or the arrival of ES Modules and CommonJS, the IIFE enabled a developer to execute code that had a safe scope without polluting the global scope or creating an entire other function (along with a function call).

Block Scope

By now we're well familiar with global scope pollution traps set by var variables. But wouldn't it be nice to be able to create portions of code that aren't functions where variables exist? For example:

var loggedIn = true;

if (loggedIn) {
  var username = "Joey";
}

It seems like a perfectly reasonable expectation that the username variable should only be declared and brought into scope if loggedIn is true. However, that's just not the case with var variables. It turns out that we've polluted our global scope:

var loggedIn = true;

if (loggedIn) {
  var username = "Joey";
}

console.log(username);
// expected result: "Joey"

A similar issue gets observed with when we use a var variable in the initialization statement in the head of a for Loop:

for (var i = 0; i <= 10; i++) {
  // doing some work
}

// we're able to access i outside of the loop:
console.log(i);
// expected result: 11

To address these issues, we can utilize block scoped variables. Variables that are only available within a set of curly brackets {} are said to be block scoped. The code within a set of curly brackets {} is referred to as a code block. Using the let and const declaration keywords instead of var is one way to achieve a block scoped variable.

Let's go back to our loggedIn example:

var loggedIn = true;

if (loggedIn) {
  const username = "Joey";
  console.log(username);
  // expected result: "Joey"
}

console.log(username);
// expected result: ReferenceError: username is not defined

Now username is only accessible in the scope of the if conditional block! Neat. Now let's look at the for Loop example again:

for (let i = 0; i <= 10; i++) {
  console.log(i);
  // expected result: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}

// we're able to access i outside of the loop:
console.log(i);
// expected result: ReferenceError: i is not defined

This time around i is only available within the for Loop block and re-binds itself to the head of the loop as long as the loop runs.

let and const are neat ways to help avoid global scope pollution without having to lean on IIFEs.

We can also create code blocks ad hoc:

{
  const hugeDataObject = [
    // some huge data block, like an array of objects
  ];
}

In this silly little pseudocode example we've used a const variable to scope a fictitious data array to a block of code. Assuming we are doing some work within the {} block, this arrangement tells the JavaScript engine that once that work's done, it's safe to do garbage collection on hugeDataObject, freeing up memory and allowing the engine to optimize itself.

It's worth noting that let and const variables, when declared outside of any other code block, are considered to be globally scoped. However, global let and const variables are not available as properties on the global object/window in the way that var variables are:

var var1 = "Howdy";
let var2 = "Hello";
const var3 = "Hi";

console.log(window.var1);
// expected result: "Howdy"

console.log(window.var2);
// expected result: undefined

console.log(window.var3);
// expected result: undefined

The with keyword is another, older way to achieve block scoping, although its usage is considered an anti-pattern.

with is a shorthand way to update multiple properties on an object:

var person = {
  age: 33,
  favoriteNumber: 66,
}

with (person) {
  age = 34;
  favoriteNumber = 666;
}

console.log(person.age);
// expected result: 34

console.log(person.favoriteNumber);
// expected result: 666

What's happening here is that the with keyword creates a new scope out of the object it's given. A quirk of this behavior is notable if you attempt to update a non-existent property on the object:

var person = {
  age: 33,
  favoriteNumber: 66,
}

with (person) {
  secondFavoriteNumber = 420;
}

console.log(person.secondFavoriteNumber);
// expected result: undefined

console.log(secondFavoriteNumber);
// expected result: 420

console.log(window.secondFavoriteNumber);
// expected result: 420

Because with creates its own lexical scope, it treats a non-existent property it's trying to access on its given object as any other identifier; outside of strict mode it creates that property as an identifier, not on the object it's given but at the global scope. This is why we're not able to access person.secondFavoriteNumber, but we are able to access secondFavoriteNumber and window.secondFavoriteNumber.

This behavior is an anti-pattern, and disallowed in strict mode (just like creating variables without using a declaration keyword).

Another, lesser known way of achieving block scope is within the catch portion of a try...catch statement:

try {
  someFunctionThatDoesntExist();
} catch (error) {
  console.log(error); // error works here
}

console.log(error);
// expected result: ReferenceError: "error" is not found

Module Scope

ES6 introduces first class support for modules, which is a way to organize functionality in your code. You can bundle common functionality into a module file, export the data or functionality you want from that module, and then import that exported data or functionality elsewhere in your program.

Say you have a function greeting() in a greeting.js file that is located within a modules directory. If you want to use that greeting() functionality elsewhere, you can now export it:

// modules/greeting.js:
export function greeting(name) {
  console.log(`Howdy ${name}`);
}

By using the export keyword, you are making that function available to be imported in other files in your program. For instance, in a top-level index.js file:

// index.js:
import { greeting } from "./modules/greeting.js";

greeting("Joey");
// expected result: "Howdy Joey"

For folks who have worked with JavaScript prior to ES6, this is a pretty radical change in how we can write and share functionality. This post will not get into all of the details of modules (that could very well be its own post or series of posts), but there are implications for our discussion of scope. When you create a module file it creates its own scope.

// modules/coolNumbers.js:
var count = 420;
var coolNumber = 66;

function logCount() {
  console.log(count);
}

export { coolNumber, logCount };

We have created a new module called coolNumbers. In it we have two variables (count, with the value of the number 420 and coolNumber, with the value of 66) and one function, logCount(), which will simply log the value of count. We are exporting coolNumber and logCount, but not count.

Back in index.js, let's import coolNumber and logCount:

// index.js:
import { coolNumber, logCount } from "./modules/coolNumbers.js";

console.log(coolNumber);
// expected result: 66

console.log(count);
// expected result: ReferenceError: count is not defined

We can easily log out the value of coolNumber, but since we didn't export count and it's not available for import, we predictably get a ReferenceError when we try to use count. However, if we call the logCount() function that we import:

// index.js:
import { coolNumber, logCount } from "./modules/coolNumbers.js";

logCount();
// expected result: 420

...then we get 420. What this demonstrates is that variables and functionality that are defined within a module are scoped to that module. In this case, count is not exported, but is available within the module that it's defined in, so logCount can still access its value. Even if something is not made publicly available through export, it is available privately within that module and can still be worked with. This is an example of module scope.

Other peculiarities with modules and scope:

  • import and export statements must be done at the top-level scope of their usage. In order words, you cannot do conditional importing or exporting within an if statement inside of a module
  • a module file does have access to a window or global scope, but accessing the global scope from inside of a module is frowned upon.

Summary

  • Scope refers to a set of rules for how variables and their values get accessed throughout the lifecycle of a program. There are four units of scope: global scope, function scope, block scope, and module scope
  • If a variable has been declared outside of any other function or code block, it is said to be in the global scope; these are accessible throughout the program
    • All variables (declared using var, let, or const) can be globally scoped, but only var variables are accessible as properties on the global object
  • Variables declared within a function are said to be function scoped; these are accessible within the function they're declared and any functions nested within that function
    • var variables declared within a function are function scoped
    • Scoping variables and functionality within functions, including Immediately Invoked Function Expressions (IIFEs), is a strategy for avoiding scope pollution
  • Variables that are only available within a set of curly brackets {} are said to be block scoped
    • let and const variables declared within a block are block scoped
    • The with keyword also does block scoping, but it's an anti-pattern to use this technique (and disallowed in strict mode)
    • The catch clause in a try...catch statement is also block scoped
  • Variables declared within a module are said to be module scoped. These are accessible within the module that they're defined, or publicly if they are exported from that module

Sources / Further Reading