JavaScript Scope
By Flavio Copes
Learn how JavaScript scope decides where a variable is visible, covering global, function and block scope and how var differs from let and const.
Scoping is the set of rules that’s defined in a programming language to determine the value of a variable.
JavaScript uses lexical scoping, which means that the value of a variable is defined by its position when it’s written. Not when it’s called, which is something that happens with the alternative, dynamic scoping.
Scope is the set of variables that’s visible to a part of the program.
We have a global scope, block scope and function scope. If a variable is defined outside of a function or block, it’s attached to the global object and it has a global scope, which means it’s available in every part of a program.
There is a very important difference between var, let and const declarations.
A variable defined as var inside a function is only visible inside that function. Just like function parameters.
A variable defined as const or let on the other hand is only visible inside that block where it resides.
It’s important to understand that a block (identified by a pair of curly braces) does not define a new scope for var, but it does for let and const. A new scope for var is only created when a function is created, because var does not have block scope, but function scope.
Let’s see how that plays out with an if block. With var, the variable leaks out of the braces:
if (true) {
var fruit = 'banana'
}
console.log(fruit) // 'banana'
With let or const, the same name stays trapped inside the block:
if (true) {
let fruit = 'banana'
}
console.log(fruit) // ReferenceError: fruit is not defined
A for loop shows the same gap. With var, the iterator is still there after the loop ends:
for (var i = 0; i < 3; i++) {
// ...
}
console.log(i) // 3
With let, it is not:
for (let i = 0; i < 3; i++) {
// ...
}
console.log(i) // ReferenceError: i is not defined
That leak matters more once you add a delayed callback. Here is the classic var-in-loop gotcha with setTimeout:
const fruits = ['banana', 'pear', 'apple']
for (var i = 0; i < fruits.length; i++) {
setTimeout(() => {
console.log(fruits[i])
}, 100)
}
You might expect banana, pear, apple. What you get is undefined three times.
Why? There is one shared i for the whole function (or script). By the time the timers fire, the loop is done and i is 3. fruits[3] does not exist.
Change the loop variable to let and each iteration gets its own i:
const fruits = ['banana', 'pear', 'apple']
for (let i = 0; i < fruits.length; i++) {
setTimeout(() => {
console.log(fruits[i])
}, 100)
}
Now each callback closes over the i from that turn of the loop, and you get the three fruit names.
Inside a function, any var variable defined in it is visible throughout all the function code, even if the variable is declared at the end of the function it can still be referenced in the beginning, because JavaScript before executing the code actually moves all variable declarations on top (something that is called hoisting). To avoid confusion, always declare var variables at the beginning of a function.
This is what I mean. Even if you declare a var variable at the end of a function, its declaration is moved to the top:
function run() {
console.log(`${name}`)
var name = 'Flavio'
}
run()
This prints “undefined”, because what actually happens is:
function run() {
var name
console.log(`${name}`)
name = 'Flavio'
}
run()
let and const are also hoisted to the top of their block, but they stay uninitialized until the declaration line runs. That gap is the temporal dead zone (TDZ). If you read the variable before that line, you get a ReferenceError, not undefined.
console.log(name) // ReferenceError: Cannot access 'name' before initialization
let name = 'Flavio'
The same pattern with var would print undefined. Be careful with that difference when you move declarations around.
In JavaScript, variables of a parent function are made available to inner functions as well. The scope of an inner function also includes the scope of a parent function, and this is called closure (we’ll talk more extensively about this later).
There is one little thing you need to be aware of. In non-strict mode, if you use a variable without declaring it, wherever you do that, that variable is going to be attached to the global scope. Which can be a bad source of bugs. So, make sure you always declare variables before using them. Just be aware of this, but it’s just another reason to use strict mode by default, which solves this issue. We’ll talk about strict mode later.
Remember: any variable defined in a function (or block) with the same name as a global variable takes precedence over the global variable, shadowing it.
This prints undefined:
var name = 'Roger'
function run() {
console.log(`${name}`)
var name = 'Flavio'
}
run()
and this raises an error ReferenceError: Cannot access 'name' before initialization:
let name = 'Roger'
function run() {
console.log(`${name}`)
let name = 'Flavio'
}
run()
That second example is the temporal dead zone again. The inner let name shadows the outer one for the whole function body, so the console.log cannot reach 'Roger' either.
Prefer let and const in new code
Learn var, because you will read it in older code. For new code, use const by default and let when you need to reassign. Both are block-scoped, which matches how most people think about curly braces. That habit removes a whole class of var surprises.
Module scope
ES modules add another scope. Top-level let, const, and var in a module are scoped to that module. They are not properties of the global object the way a classic script’s top-level var is.
// utils.js
export const appName = 'Shop'
// app.js
import { appName } from './utils.js'
console.log(appName)
Each file gets its own module scope. Values move between files through import and export, not by leaking into window or globalThis.
Want me to talk about your product? You can sponsor this site.
Related posts about js: