Mastering the Mysteries of JavaScript Scopes

in guides by Ramo Mujagic15 min read
Mastering the Mysteries of JavaScript Scopes

While writing your programs in JavaScript you will quickly get familiar with creating variables and functions, assigning values, using and updating them. But, have you ever asked yourself how the JavaScript knows which variable or function is accessible by the given statement, or how are these things managed internally?

This is where scopes and the scope chain come into play. The concept of scopes and scope chain plays a vital role in determining the accessibility of variables and functions. Mastering their intricacies can make the difference between writing clean, maintainable code and struggling with bugs and unexpected behavior.

In this article, we will dive deeper into the inner workings of scopes and explore how they impact your code. That being said, this is not an article for total beginners and basic understanding of JavaScript is required.

Lexical Scope

From the perspective of a program, it is very important where you place variable or function declarations, as well as how and when you are using them.

The reason it's important is because of lexical scope and how the program behaves in relation to it. By definition, lexical scope determines the visibility and accessibility of variables and functions based on their position in the source code. It makes code easier to reason about and helps avoid naming conflicts between variables.

In general, JavaScript has different kinds of scopes: global scope, function scope and, more recently, block scope. Some might argue that there is also module scope, but we will not discuss it in this article.

You might have also heard about the term local scope. It is a more general term that can refer to both function scope and block scope, depending on the context.

Global Scope

Let's immediately start with an example. Consider the following code.

1var movies = [
2  {
3    title: "District 9",
4    releaseYear: 2009,
5    genre: ["Action", "Sci-Fi", "Thriller"]
6  },
7  {
8    title: "Transformers: Age of Extinction",
9    releaseYear: 2014,
10    genre: ["Action", "Adventure", "Sci-Fi"]
11  }
12];
13 
14console.log(movies);

It is easy to spot that the movies variable is declared, initialized and printed to console using console.log. This is, however, not what I want to point out.

Even now, with a program as simple as this, there is a scope created. This scope is referred to as global scope. It exists in every program and contains all other scopes that we could possibly define.

Think of it as a container where variables and functions can be declared and other scopes created.

Global scope container

When a variable or function is defined in global scope, it can be accessed from any other scope through the program. There are some exceptions to this, but it should not bother you for now.

There is also one more important aspect of global scope, in JavaScript, called global object. Global object always exists in the global scope.

Global object shown inside the global scope

We all know that JavaScript code can be executed in the browser. However, there are different environments that can run JavaScript code like, for example, Node.js runtime.

Depending on the environment where JavaScript is hosted, a different global object might be present. In browsers, the global object is more commonly known as Window, but in Node.js it's referred to as global.

Since the global object is part of JavaScript language, in ES2020 specification, it has finally received a unique identifier globalThis.

Function Scope

Whenever you create a function, function scope is created along with it. No matter if you use function definition, function expression or even arrow function, function scope is always present.

It exists inside the function body. Every variable, or even other functions, created inside the body will be part of the function scope.

1var movies = [...]
2
3function printTitle(index) {
4  var movie = movies[index]
5  var title = movie.title
6
7  console.log(title)
8}
9
10printTitle(1)

Look at the body of the function printTitle, space between {...}, it has two variables defined as movie and title. Since variables are defined inside the function body, they are also part of the function scope.

Also, notice that the movies variable is referenced, but it's not defined in the function scope. This variable can still be used by the function, since it's created in the global scope. Remember, every variable created in the global scope is accessible in all other scopes.

Function scope inside the global scope

We can see how function scope is created inside the global scope when function is defined. In the image, the global object is removed since we will not talk about it anymore, but keep in mind, it is always there in the global scope.

You might have noticed that the identifier index is marked with a blue color. When we defined the function printTitle, we did it in the following way function printTitle(index) {...}. In other words, it has one parameter called index and, by default, every function parameter is added to the function scope.

If you try to access index, movie or title outside function scope, an error will be thrown.

1var movies = [...]
2
3function printTitle(index) {
4  var movie = movies[index]
5  var title = movie.title
6
7  // 🖨 Prints to the console
8  console.log(title)
9}
10
11printTitle(1)
12
13// 🚫 ReferenceError: movie is not defined
14console.log(movie)

Variables defined in function scope can not be accessed outside the function. When referencing a variable that is not defined in the scope, JavaScript will throw ReferenceError.

In the example, we have defined variables using the var keyword, but the same behavior would occur for variables defined via let or const keywords.

Block Scope

Before ES2015, block scoping did not exist in JavaScript. Reason being that the only way to create variables was using the var keyword.

When let and const keywords are introduced, block scoping has become possible. Having block scoped variables helps a lot to prevent name pollution and accidental overrides of variables that are defined outside the block scope.

Variables defined using var keyword can only be function scoped. When used inside a block, such a variable will leak its definition until the first function scope is reached.

Block scope is defined by {} and can be put basically in any part of the program. Consider the following code.

1var movies = [...];
2
3{
4  var listOfTitles = movies.map((movie) => movie.title);
5  let listOfReleaseYears = movies.map((movie) => movie.releaseYear);
6
7  // 🖨 Prints to the console
8  console.log(listOfTitles);
9
10  // 🖨 Prints to the console
11  console.log(listOfReleaseYears);
12}
13
14// 👇 Leaked definition
15// 🖨 Prints to the console
16console.log(listOfTitles);
17
18
19// 🚫 ReferenceError: listOfReleaseYears is not defined
20console.log(listOfReleaseYears);

Program above is making use of block scope by putting variables inside {}. Inside the block scope, variables listOfTitles and listOfReleaseYears are defined, initialized and printed to console using console.log. This works as expected, the content of both variables is printed to the console.

When we try to print them again, outside the block scope, listOfTitles will be printed but trying to reference listOfReleaseYears will throw a ReferenceError. Any idea why is this happening?

If we look back at the code, notice that listOfReleaseYears is defined using the let keyword. This is the reason why the variable listOfReleaseYears can not be used outside the block scope where it was initially created. On the other hand, listOfTitles is defined using the var keyword and it does not see block scope.

Using {} to create block scope might not be the most common way to do it. More often than not, block scopes will be created using statements like if...else or switch. In fact, for and while loops are also block scoped. It often can catch developers off guard since they might think that the scope inside the loops behaves the same as the function scope, which is not the case.

1var movies = [...];
2
3function printTitle(index) {
4  var movie = movies[index];
5  var title = movie.title;
6
7  if (title.length > 12) {
8    const truncatedTitle = `${title.substring(0, 12).trim()}...`;
9
10    // 🖨 Prints to the console
11    console.log(truncatedTitle);
12  }
13
14  // 🚫 ReferenceError: truncatedTitle is not defined
15  console.log(truncatedTitle);
16}
17
18printTitle(1);

The variable truncatedTitle is defined inside the block scope created by the if statement, and it will be accessible as long as we reference it from the block scope. However, if we try to reference truncatedTitle outside the block scope, in function scope, ReferenceError will be thrown.

To get a better picture, how block scope fits into our program, let's visualize it to show scope boundaries.

Block scope defined inside function scope

If you look at the image and the source code, can you spot similarities? It should be obvious that scopes are defined by the source code. When you write your program, think about scopes as well. They will always be involved.

Nested Scope

In most non-trivial programs, chances are, many of the so-called nested scopes will probably be created. Nested scope does not represent a new kind of scope, remember, there are only global, function and block scopes in JavaScript.

The term nested scope is used to describe a situation in which one scope is defined inside another scope. With that being said, we have already encountered nested scope in our previous examples.

1var movies = [...];
2
3function printTitle(index) {
4  var movie = movies[index];
5  var title = movie.title;
6
7  if (title.length > 12) {
8    const truncatedTitle = `${title.substring(0, 12).trim()}...`;
9    console.log(truncatedTitle);
10  }
11
12  console.log(title);
13}
14
15printTitle(1);

Can you spot which scope would be considered as nested? It is a block scope created by the if statement.

As we already know, the function printTitle will create a function scope. However, it only has global scope as its outer scope. When scope is defined directly inside the global scope, it is usually not considered to be nested.

The global scope is the outermost scope and any other scopes will, by default, be created inside it. For this reason, nesting usually implies a scope to be defined inside another scope that is not the global scope.

Visualized nested scope

In our example, the block scope ticks all the boxes. It is nested inside another scope, created by the printTitle function, and is not directly in the global scope.

Many nested scopes can be defined through the program. This creates a hierarchical relationship between the scopes, where the inner scope has access to its own variables as well as the variables of outer scopes. This nesting of scopes allows for more complex programs to be built, as it guarantees separation of concerns and the creation of reusable code.

Let's get back to our program and shed some light on scope related terminology. If we look from the perspective of the printTitle function, we can say that the block scope, created by the if statement, is a child or inner or nested scope. In the same note, the function itself has the global scope as its parent scope.

Continuing, if we look from the perspective of the block scope, the function scope will be its parent scope or the first outer scope while the global scope will be it's second outer scope.

Another important term is current scope. It refers to the scope where the JavaScript engine is currently executing code. So, hypothetically, if the engine is executing code in the block scope, we can refer to it as the current scope.

If you find it a bit hard to understand nested scope and related terminology, try to read it again at a slower pace and don't give up so easily.

Scope Chain

So far, we have talked a lot about lexical scope and how it works, but there is one more important topic, closely related to lexical scope, that we need to cover.

We have said that scope determines visibility and accessibility of variable and function declarations. You might be thinking, but how does it work? It has something to do with the so-called scope chain.

Scope chain is a mechanism used to determine the accessibility of variables and functions through the program. It defines the order of scope resolution during the runtime/execution phase. To better understand this, let's talk about scope chain lookup first.

Lookup

When a referenced variable or function can not be found in the current scope, the engine has to perform a search in the scope chain. This search is usually called scope chain lookup.

1var movies = [...];
2
3function printTitle(index) {
4  var movie = movies[index];
5  var title = movie.title;
6
7  if (title.length > 12) {
8    const truncatedTitle = `${title.substring(0, 12).trim()}...`;
9    console.log(truncatedTitle);
10  }
11
12  console.log(title);
13}
14
15printTitle(1);

Take a look at the function scope, the variable movies is referenced, but it is not defined inside the function scope. The same is for the block scope, the variable title is referenced, but it is not defined in the block scope.

So how does the engine know what is the value of each of those variables? This is where scope chain lookup comes into play.

When a variable or function is referenced, the engine starts at the current scope, looking for definition. Remember, current scope is the scope where the engine is currently executing the code. If the reference is not resolved in the current scope, the engine moves up in the scope chain searching for the reference. This process is repeated until the reference is resolved or the end of the scope chain is reached. In case that the reference could not be resolved, the engine will throw ReferenceError.

Visualization of scope chain lookup

The variable movies is referenced in the function scope. Since it is not defined in the same scope, it could not be found. The engine moves upwards in the scope chain and tries to resolve the reference. The next scope in the chain is the global scope. Since the variable movies is defined in the global scope, the engine can resolve the reference and get the value.

Similarly, when the engine tries to access title from block scope, it has to do a lookup, go upwards in the scope chain trying to resolve the reference. In this case, the reference will be resolved in the function scope since the variable title is defined in there.

To recap, scope chain lookup refers to the process of searching through a list of scopes to find a referenced variable or function. The engine will start at the current scope and move upwards the scope chain until the reference is resolved or ReferenceError is thrown.

Peek under the hood

At this point, you should have a better understanding of lexical scope and scope chain. It's time to go one step deeper and look under the hood to see how the engine creates and maintains a scope chain.

Scope chain is constructed and maintained during the execution phase. Just before the engine starts executing a piece of code, it needs to create a structure that holds variables and functions declarations. This structure is known as the lexical environment.

Visualization of lexical environment

Before you ask, yes, lexical environment is related to lexical scope. It holds information about identifiers and their values that are defined in lexical scope.

Each lexical environment has a reference to its parent environment, creating the scope chain. Do not be concerned about how the engine implements scope chain structure under the hood, after all, it's a job of the engine.

Scope chain constructed using references between lexical environments

Now, when we have a good picture of how it works, let's use the same code from previous examples and walk through steps that the engine might take to create the scope chain.

This will not be a detailed description on how the engine executes the code. Our goal here is to point out parts that are important for the scope chain creation.

1var movies = [
2  {
3    title: "District 9",
4    releaseYear: 2009,
5    genre: ["Action", "Sci-Fi", "Thriller"]
6  },
7  {
8    title: "Transformers: Age of Extinction",
9    releaseYear: 2014,
10    genre: ["Action", "Adventure", "Sci-Fi"]
11  }
12];
13
14function printTitle(index) {
15  var movie = movies[index];
16  var title = movie.title;
17
18  if (title.length > 12) {
19    const truncatedTitle = `${title.substring(0, 12).trim()}...`;
20    console.log(truncatedTitle);
21  }
22
23  console.log(title);
24}
25
26printTitle(1);

Obviously, code first needs to be parsed and compiled. At this point, the engine already knows about lexical scopes. Just before execution is started, the engine will create the global lexical environment.

One important step, more commonly known as hoisting, will occur during the creation of every lexical environment. The engine will hoist variables and functions to the top of their respective scopes and assign them their default values.

Hoisting behaves differently for variables defined using let and const keywords.

Taking the hoisting into account, a freshly created global lexical environment, for our program, might look like this.

1{
2  movies: undefined,
3  printTitle: <ref. to function printTitle>,
4  outer: null
5}

Finally, the engine starts executing the code, line by line. It will start with var movies = [...]. Since the movies variable is already created, courtesy of hoisting, the engine will create an array of objects in memory and save the reference to that array into the movies variable.

Next, the engine will see the function printTitle part of the code. However, there is nothing that needs to be done here. During hoisting, the engine has already created the printTitle identifier and assigned, to it, a reference to the printTitle function.

Good, the engine has made some progress and the global lexical environment looks like this now.

1{
2  movies: <ref. to array>,
3  printTitle: <ref. to function printTitle>,
4  outer: null,
5}

The variable movies has been updated, and it now holds the reference to the array of movies saved in the memory.

It is important to point out that the global lexical environment contains movies and printTitle identifiers only because we have put them into the global scope.

Global lexical environment populated with identifiers from the global scope

Did you notice the outer identifier in the global lexical environment? This identifier is injected by the engine whenever a new lexical environment is created. It has a value of null since the engine is executing code in the global scope.

Continuing, the engine will see that we have called the printTitle function passing number 1 as an argument. Calling a function will trigger function execution. However, before the code inside the function gets executed, the lexical environment for the function itself will be created and hoisting, for the function scope, will occur.

1{
2  index,
3  movie: undefined,
4  title: undefined,
5  outer: <globalLexicalEnvironment>
6}

Since the function has received 1 as the first argument, the engine will immediately initialize the parameter index and set its value to 1.

Notice that outer identifier has reference to the global lexical environment. This reference allows the engine to access the global lexical environment. You know what that means? That's right, the engine has just created the scope chain.

Scope chain created by linking two lexical environment

When executing var movie = movies[index] the engine will see that the variable movies does not exist in the current scope, and it will use the reference, saved in the outer identifier, to access the global lexical environment. Now, it will start looking for the movies identifier and resolve its value. Does this sound familiar? The engine has just performed a scope chain lookup.

The variable movies holds the reference that points to the movies array saved in memory. According to our code, movies[index], the engine will take the object on the index 1 and save its reference into the movie variable. At the end, reference to the second object in the movies array is saved into the movie variable.

Continuing, statement var title = movie.title is next to execute. To initialize the title variable, the engine will need to resolve the movie identifier. Since it's declared in the local scope, scope chain lookup will not be required. As we already know, the variable movie holds reference to the movies[1] object. The engine will take the value from the property title which is string "Transformers: Age of Extinction" and save it into the title variable.

It is very important to know when the engine is passing/returning reference and when the actual value. In general, only primitive data types are passed by value, and non-primitive ones are passed as a reference.

The engine has just executed the first two lines inside the function. Here is how the local (function) lexical environment looks like now.

1{
2  index: 1,
3  movie: <ref to movies[1]>,
4  title: “Transformers: Age of Extinction”,
5  outer: <globalLexicalEnvironment>
6}

Both movie and title are updated. Notice that the movie variable now holds reference to the movies[1] object, while title holds the string value "Transformers: Age of Extinction".

Woah, the engine is flying through our program and we are already in the if (title.length > 12) part of the code. When the engine checks title.length > 12, it will get true as a result. Remember, title is still part of local (function) scope, so no scope chain lookup is required.

Because of the truthful condition, the engine will create a new lexical environment for the if statement. Remember from before, this lexical environment is created only because we used const inside the if statement.

After creating a new lexical environment for the if block, taking hoisting into consideration, the engine will give us something like this.

1{
2  truncatedTitle,
3  outer: <functionLexicalEnvironment>,
4}

The engine has created yet another lexical environment, and look where does the outer identifier points to? That's right, to the function lexical environment, and now we have scope chain that consists of 3 lexical environments.

Scope chain created by linking two lexical environment

Notice that truncatedTitle is not initialized with any value. At the moment, truncatedTitle is in the so-called TDZ (Temporal Dead Zone). The engine knows about the truncatedTitle variable, but it can not be used before it's initialized or ReferenceError error will be thrown. This only happens when using variables declared with let and const.

The engine starts executing the first line of code inside the block const truncatedTitle = `${title.substring(0, 12).trim()}...`. Here is where the engine will initialize the truncatedTitle variable and, after this point in time, TDZ for truncatedTitle ends.

The engine will search the scope chain to resolve the title identifier and get the value. This identifier will be resolved in the function scope. Because of substring and trim string methods, the engine will return the first 12 characters, removing potential empty space along the way. At the end, the value saved into the truncatedTitle will be string "Transformers...".

1{
2  truncatedTitle: “Transformers...”,
3  outer: <functionLexicalEnvironment>,
4}

Just before the end of the block scope, console.log(truncatedTitle) will be executed and the value of the truncatedTitle variable will be printed to the console.

Continuing, the engine has to exit the block scope. Usually, when the engine exits a lexical scope, the lexical environment for that scope is typically destroyed and garbage-collected by the engine's memory management system. This means that any variables and functions declared in that scope are no longer accessible and do not occupy memory. This is an automatic process that happens in the background and usually the developer does not have to worry about it.

Taking that into account, when the engine exits the block scope, the block lexical environment will be destroyed, which means that variable truncatedTitle no longer exists and its value "Transformers..." is removed from the memory.

If we take a look at the scope chain, the lexical environment created for the block scope is removed from the scope chain. The engine will manage this automatically when the lexical environment is destroyed.

Scope chain after removing lexical environment created for the block scope

Next in order of execution is console.log(title). The engine is executing in the function scope again. This means that the variable title is accessible from the local lexical environment and scope chain lookup is not required. When the engine is done executing this line, the value of the variable title will be printed to the console.

Continuing, the engine has to exit the function and garbage-collection will kick in by destroying the lexical environment and releasing used memory. Now, if we look at the scope chain, it has only one lexical environment left.

Scope chain after removing lexical environment created for the function scope

Finally, there is no code left to be executed, and the engine has reached the end of our program. It terminates the process and releases all the resources that were used during execution time, including memory and CPU time.

Conclusion

Lexical scope and scope chain are fundamental concepts in JavaScript. Lexical scope determines which variables and functions are accessible within a certain code block.

The scope chain, on the other hand, is an internal structure managed and used by the JavaScript engine to resolve variables and functions during runtime, following the hierarchy of lexical scopes. Understanding these concepts is important for writing efficient and effective JavaScript code.

I tried to give more details about lexical scope and related terminology, but also to dive deeper into scope chain management and explain how it works under the hood. I really hope that this was helpful and you have more knowledge about this topic than before reading this article.

More like this

Ready for more? Here are some related posts to explore