Let's revisit something relating to variables. Each variable we declare has a certain level of visibility that determines when we can actually use it. In human-understandable terms, just because we declare a variable doesn't mean that it can be accessed from anywhere in our code. There are some basic things we need to understand, and this whole area of understanding this falls under a topic known as variable scope.
In this tutorial, I'm going to be explaining variable scope by looking at common cases that we've (mostly) already seen. This is a pretty deep topic, but we are just going to scratch the surface here. We'll see variable scope creep up in many subsequent tutorials where we will extend on what we learn here.
Onwards!
Global Scope
We are going to start our exploration of scope at the very top with what is known as global scope. In real life, when we say that something can be heard globally, it means that we can be anywhere in the world and still be able to hear that...something:

In JavaScript, much the same applies. A global binding can be accessed by other code in the same page. In a classic script, a declaration at the top level, outside any function or block, has global scope. JavaScript modules have their own top-level scope.
To illustrate this, let's take a look at the following example:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Variable Scope</title>
</head>
<body>
<script>
let counter = 0;
alert(counter);
</script>
</body>
</html>
Here, we are simply declaring a variable called counter and initializing it to 0. By virtue of this variable being declared directly inside the script tag without being placed inside a function, the counter variable is considered to be global. What this distinction means is that our counter variable can be accessed by any code that lives in our document.
The below code highlights this:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Variable Scope</title>
</head>
<body>
<script>
let counter = 0;
function returnCount() {
return counter;
}
alert(returnCount());
</script>
</body>
</html>
In this example, the counter variable is declared outside of the returnCount function. Despite that, the function can read the enclosing binding. We call returnCount() first and pass its returned value to alert.
At this point, you are probably wondering why I am pointing this out. We've been using global variables all this time without really noticing it. All I am doing here is formally introducing you to a guest that has been hanging around your party for a while.
Note: What Global Really Means
I've been pretty vague in defining what exactly global means. That is deliberate, for formally describing it will involve a whole lot more backstory to make sense of everything. If you are familiar enough with JavaScript (or are feeling adventurous), read on. If not, feel free to skip this note and move on to the next section. We'll revisit this later.
In a browser, window is the global object. Top-level var declarations in a classic script normally create properties on that object. Top-level let and const declarations instead create global lexical bindings without creating matching properties on window.
In our example, we used let counter. On a fresh page with no existing window.counter property, this comparison displays false:
alert(window.counter === counter);
The counter binding is still available to other scripts in this page even though it is not a window property. A module's top-level declarations are different again: they belong to that module rather than becoming global bindings.
Local Scope
Now, things get a little interesting when we look at things that aren't globally declared. This is where understanding scope really starts paying dividends. As we saw earlier, a variable declared globally is accessible inside a function:
let counter = 0;
function returnCount() {
return counter;
}
The opposite doesn't hold true. A variable declared inside a function will not work when accessed outside of a function:
function setState() {
let state = "on";
}
setState();
alert(state); // ReferenceError if no outer state binding exists
In this example, state is local to setState. Assuming no outer binding has that name, the direct read outside the function throws ReferenceError. It does not produce an undefined value. Return a value or provide an intentional interface when the caller needs access to this state.
Note: Using Variables Without Declaring Them
In a non-strict classic script, assigning to an undeclared name can create a global property. This example assumes no existing state binding:
function setState() {
state = "on";
}
setState();
alert(state) // "on"
This accidental global is a consequence of an undeclared assignment in non-strict code. Strict code, including JavaScript module code, throws ReferenceError instead. Declare the binding explicitly in the scope that should own it; omitting a declaration is not a private-state mechanism.
Miscellaneous Scoping Shenanigans
Since we are talking about JavaScript here, things would be too easy if we just left everything with variable scope as they stand now. In the following sections, I am going to highlight some quirks that you need to be famliar with.
Block Scoping
Our code is made-up of blocks...lots and lots of blocks. What exactly is a block? A block is a collection of JavaScript statements almost always wrapped by curly brackets. For example, let us take a look at the following code:
let safeToProceed = false;
function isItSafe() {
if (safeToProceed) {
alert("You shall pass!");
} else {
alert("You shall not pass!");
}
}
isItSafe();
Counting the pair of curly brackets, there are three blocks here. One block is the region contained by theisItSafe function itself:
let safeToProceed = false;
function isItSafe() {
if (safeToProceed) {
alert("You shall pass!");
} else {
alert("You shall not pass!");
}
}
isItSafe();
The second block is the if statement region:
let safeToProceed = false;
function isItSafe() {
if (safeToProceed) {
alert("You shall pass!");
} else {
alert("You shall not pass!");
}
}
isItSafe();
The third block is the region covered by the else statement:
let safeToProceed = false;
function isItSafe() {
if (safeToProceed) {
alert("You shall pass!");
} else {
alert("You shall not pass!");
}
}
isItSafe();
Any variable declared inside a block using let or const is local to that block and any child blocks contained inside it. To better understand this, take a look at the following code that is a variation of the isItSafe function from earlier:
function isThePriceRight(cost) {
let total = cost + 1;
if (total > 3) {
alert(total);
} else {
alert("Not enough!");
}
}
isThePriceRight(4);
We declared the total variable as part of the function block. We are accessing this variable inside the if block. What do you think will happen? The total variable is totally (haha!) accessible here, because the if block is a child of the function block. To put it in the lingo of our times, the total variable is considered in-scope of the alert function.
What about the following situation?
function isThePriceRight(cost) {
let total = cost + 1;
if (total > 3) {
let warning = true;
alert(total);
} else {
alert("Not enough!");
}
alert(warning);
}
isThePriceRight(4);
We have a warning variable declared inside the if block. The later alert is outside that block. Assuming there is no outer binding with the same name, reading warning there throws ReferenceError. The block-local binding is out-of-scope.
Note: Declaring Variables with the var Keyword!
Earlier, we looked at var as the older declaration form. The let and const declarations give us block scoping, which makes keeping temporary variables local easier. Replacing var in existing code still needs review because visibility and initialization behavior change. Let's look at that difference:
Variables declared with var do not have block scoping. If we modify the example from earlier to have our warning variable be declared using var and instead of let, our code will look as follows:
function isThePriceRight(cost) {
let total = cost + 1;
if (total > 3) {
var warning = true;
alert(total);
} else {
alert("Not enough!");
}
alert(warning);
}
isThePriceRight(4);
Earlier, reading warning outside its block threw an error. With var, that binding belongs to the surrounding function, so the later alert can read it. In this example, the assignment runs and the alert displays true. Function-wide visibility does not guarantee that an assignment inside a conditional has executed.
For these reasons, I prefer block-scoped declarations. Use const when the binding does not need reassignment and let when it does. Keep the variable in the scope that actually needs to share it.
Correction:
Thanks to Kyle Murray / krilnon
for pointing out the EcmaScript 6 angle for
let. See the
comments below for more context!
How JavaScript Processes Variables
If you thought the earlier block scoping logic was weird, wait till you see this one. Take a look at the following code:
let foo = "Hello!";
alert(foo);
When this code runs, we can reasonably state that the value of Hello! will be displayed. We would reasonably be right. What if we made the following modification where we moved the variable declaration and initialization to the end?
alert(foo);
let foo = "Hello!";
In this situation, our code throws ReferenceError because foo is read before its binding is initialized. If we replaced let with var, here is what our code would look like:
alert(foo);
var foo = "Hello!";
When this code runs, the behavior is different than what we saw earlier. You will see undefined displayed. What exactly is going on here?
Before executing a scope's statements, JavaScript sets up its declared bindings. A var binding is initialized to undefined. The let and const bindings remain uninitialized until their declarations execute. This early setup is known as hoisting, but it does not mean the engine literally moves lines of source code.
Let's dive in to see what this means. Our code initially looks like this:
alert(foo);
let foo = "Hello!";
It is tempting to rewrite this example as follows, but that rewrite changes its behavior:
let foo;
alert(foo);
foo = "Hello!";
Executing let foo; initializes foo to undefined. Our rewritten example can now display undefined, unlike the original example, which throws an error. The original read is in the temporal dead zone, which starts on entering the binding's scope and ends when its declaration initializes it. With var, we can model the setup as follows:
var foo = undefined;
alert(foo);
foo = "Hello!";
Here the binding is already initialized to undefined when the first alert runs. The later assignment still has not executed. Having a binding does not mean it already holds the intended value.
The main takeaway from all of this is as follows: please declare and initialize your variables before actually using them. While JavaScript has some affordance for dealing with cases where we don't do that, those affordances are just awfully confusing.
Closures
No conversation about variable scope can be wrapped up without discussing closures. That is, until right now. I am not going to explain closures here, for it is a slightly more advanced topic that we will cover separately in the Closures in JavaScript tutorial.
Conclusion
Well, that concludes this topic of variable scopes. This topic seems very simple on the surface, but as you can see, there are some unique characteristics that takes some time and practice to fully understand.
Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.
Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.
Your support keeps this site going! 😇

