JavaScript · Intermediate
Scope and Hoisting in JavaScript
In short: Scope is the region of code where a name is visible: JavaScript has global scope, function scope and, since ES2015, block scope for let, const and class. Hoisting means declarations are processed before the code runs: a var exists from the top of its function as undefined, a function declaration is callable before its line, and let/const cannot be touched until their line runs.
Where a name lives and when it comes to life
Every variable and function is declared somewhere, and that place decides where it can be used. A name declared at the top level of a script is global and visible everywhere. A name declared inside a function is visible only inside that function, including any functions nested within it. A name declared with let, const or class inside a pair of braces, whether an if, a loop or a bare { } block, is visible only inside those braces. Scopes nest: code looks for a name in its own scope first, then in the enclosing scope, and so on outwards to the global scope. Because that search follows the structure of the source text rather than the order of calls, JavaScript scoping is called lexical.
var is the exception that makes the topic worth a lesson. A var ignores blocks and belongs to the nearest enclosing function (or the global scope if there is none). A var declared inside an if is still there after the if, and the counter of for (var i = ...) is still there after the loop. let and const, introduced in ES2015, respect blocks, which is why modern code uses them and reserves var for old scripts.
Before running a scope, the engine registers every declaration in it. This is hoisting, and it behaves differently per kind of declaration. A var is created at the top of its function with the value undefined, so reading it before its line gives undefined rather than an error. A function declaration is hoisted together with its body, so it can be called from a line above it, which lets you put helper functions at the bottom of a file. A function expression assigned to a variable is not: the variable hoists, the function does not, so calling it early throws a TypeError because you are calling undefined.
let, const and class are also registered before the code runs, but they are placed in the temporal dead zone: from the start of the block until the declaration line executes, any read or write throws a ReferenceError. This is deliberate. A var read too early silently produces undefined and the bug surfaces somewhere else; a let read too early fails at the exact line where the mistake is.
An inner scope may declare a name that also exists outside. The inner one shadows the outer for as long as the inner scope lasts, and the outer value is untouched. Shadowing is legal but easy to misread, so avoid reusing a name for a different purpose within one function.
Assigning to a name that was never declared is a trap in old-style scripts: it silently creates a global variable. In strict mode, which is on by default in modules and classes and which this site's runner uses, the same assignment throws a ReferenceError. Declare every variable, and prefer const unless you need to reassign.
Syntax
// function scope: var belongs to the whole function
function f() {
if (true) { var a = 1; }
console.log(a); // 1: the block did not contain it
}
// block scope: let and const end at the closing brace
if (true) { let b = 2; const c = 3; }
// b and c do not exist here
// hoisting
hello(); // works: function declarations hoist with their body
function hello() {}
console.log(v); // undefined: var hoists without its value
var v = 5;
console.log(w); // ReferenceError: w is in the temporal dead zone
let w = 6;A function declaration must be a statement on its own. When a function is stored in a variable with const g = function () {} or an arrow function, it follows the rules of that variable, not of function declarations.
Function scope versus block scope
Three variables are declared inside an if block with var, let and const. Watch which ones are still visible after the block, and what happens to a loop counter.
function countStock() {
if (true) {
var boxes = 12;
let crates = 3;
const pallets = 1;
console.log("inside:", boxes, crates, pallets);
}
console.log("var after the block:", boxes);
console.log("let after the block:", typeof crates);
console.log("const after the block:", typeof pallets);
}
countStock();
for (var i = 0; i < 3; i++) {}
console.log("i after the loop:", i);
for (let j = 0; j < 3; j++) {}
console.log("j after the loop:", typeof j);Output
inside: 12 3 1 var after the block: 12 let after the block: undefined const after the block: undefined i after the loop: 3 j after the loop: undefined
boxes was declared with var, so it belongs to the whole countStock function and is readable after the if. crates and pallets ended with the block; typeof on a name that does not exist returns the string "undefined" without throwing, which is the one safe way to probe for an undeclared name. The var i loop counter leaks out with its final value 3, while let j is gone as soon as the loop ends.
Hoisting: what you can use before its line
The function greet is called before it is defined, a var is read before it is assigned, and a let and a function expression are read too early inside try blocks so the errors can be shown.
console.log(greet("Sami"));
function greet(name) {
return "Welcome, " + name;
}
console.log("before var:", price);
var price = 40;
console.log("after var:", price);
try {
console.log(discount);
} catch (err) {
console.log("let before declaration:", err.name);
}
let discount = 5;
console.log("after let:", discount);
try {
console.log(shout("hi"));
} catch (err) {
console.log("function expression before assignment:", err.name);
}
var shout = function (text) { return text.toUpperCase(); };
console.log(shout("hi"));Output
Welcome, Sami before var: undefined after var: 40 let before declaration: ReferenceError after let: 5 function expression before assignment: TypeError HI
greet works on line one because a function declaration is hoisted with its body. price exists from the start but holds undefined until its line runs. discount is in the temporal dead zone, so reading it throws a ReferenceError, caught here only to print its name. shout shows the difference between the two ways of defining a function: the var is hoisted as undefined, and calling undefined is a TypeError, not a ReferenceError.
Lexical scope, shadowing and undeclared names
An inner function reads variables from two enclosing scopes, a block shadows an outer variable, and a function assigns to a name that was never declared.
const unit = "kg";
function makeLabel(weight) {
const prefix = "net";
function format() {
return `${prefix} ${weight} ${unit}`;
}
return format();
}
console.log(makeLabel(2.5));
let level = "outer";
{
let level = "block";
console.log(level);
}
console.log(level);
function setTotal() {
total = 10;
}
try {
setTotal();
} catch (err) {
console.log(err.name + ": assigning to an undeclared name");
}Output
net 2.5 kg block outer ReferenceError: assigning to an undeclared name
format uses prefix from makeLabel, weight from the parameter list and unit from the global scope: the lookup walks outwards through the scopes that enclose it in the source. The inner let level is a separate variable that hides the outer one only inside the braces; the outer level is still "outer" afterwards. total = 10 has no declaration anywhere. This program runs in strict mode, so the assignment throws instead of quietly creating a global.
var, let and const compared
| Behaviour | var | let | const |
|---|---|---|---|
| Scope | function | block | block |
| Read before the declaration line | undefined | ReferenceError (TDZ) | ReferenceError (TDZ) |
| Redeclare in the same scope | allowed | SyntaxError | SyntaxError |
| Reassign | yes | yes | no (the binding is fixed; object contents can still change) |
| Loop counter per iteration | one shared variable | a fresh binding each iteration | cannot be incremented (fine in for...of) |
| Introduced | original language | ES2015 | ES2015 |
Common mistakes
Reading a var before it is assigned and getting undefined
Why it goes wrong: Hoisting makes the name exist from the top of the function, so there is no error, just
undefinedflowing into the next calculation and failing later asNaNor"undefined"in a string.Fix: Declare with
letorconstat the point of first use. A too-early read then fails immediately with a ReferenceError that names the variable.Expecting an if or for block to contain a var
Why it goes wrong:
varbelongs to the function, so two blocks that both declarevar tempshare one variable and overwrite each other, and a loop counter is still alive after the loop.Fix: Use
letorconstin blocks; each block then gets its own variables.JavaScript · fixfor (let i = 0; i < 3; i++) { /* i is private to the loop */ }Assigning to a name without declaring it
Why it goes wrong: In non-strict scripts
count = 1creates a global variable, so a typo such ascuont = 1silently makes a second variable instead of updating the first. In strict mode it throws a ReferenceError.Fix: Always declare with
constorlet, and use strict mode (modules and classes are strict automatically).Calling a function expression above its definition
Why it goes wrong: Only function declarations hoist with their body.
const f = () => {}followsconstrules, so an earlier call hits the temporal dead zone; withvarit callsundefinedand throws a TypeError.Fix: Define function expressions before you use them, or write helpers that must be callable early as function declarations.
Where you use this
Scope is what makes a helper variable safe. A const total inside a function cannot collide with a total in another function, so you can name things naturally without a global registry of names. Block scope goes further: a temporary inside an if or a loop body vanishes at the closing brace, which keeps the surrounding function tidy and stops a stale value from being read by mistake.
Hoisting of function declarations shapes how files are laid out. Many codebases put the main flow at the top of a file and the helper functions below it, which reads like a summary followed by details; that only works because declarations are usable before their line. Understanding the temporal dead zone also explains the ReferenceError you get when two const initialisers accidentally refer to each other, and the next lesson, closures, builds directly on lexical scope: a function remembers the scope it was created in.
function main() {
const rows = parse(readAll());
console.log(summarise(rows));
}
main();
function parse(text) { /* ... */ }
function summarise(rows) { /* ... */ }Key points
- Scope is lexical: a name is looked up in the current scope, then in each enclosing scope out to the global one.
varis function-scoped and ignores blocks;let,constandclassare block-scoped.varhoists asundefined; function declarations hoist with their body; function expressions follow the rules of the variable they are stored in.letandconstare in the temporal dead zone until their line runs, so an early access throws a ReferenceError.- An inner declaration with the same name shadows the outer one without changing it.
- In strict mode, assigning to an undeclared name is a ReferenceError; in sloppy scripts it creates a global.
typeof nameis the one expression that is safe on an undeclared name.
Try it yourself
Both loops declare their counter with var, so they share a single variable i and the outer loop runs only once. Change the declarations so each loop has its own block-scoped counter. The program should print inner 0, 1, 2 followed by outer 0, then the same again with outer 1.
for (var i = 0; i < 2; i++) {
for (var i = 0; i < 3; i++) {
console.log("inner", i);
}
console.log("outer", i);
}inner 0
inner 1
inner 2
outer 0
inner 0
inner 1
inner 2
outer 1for (let i = 0; i < 2; i++) {
for (let i = 0; i < 3; i++) {
console.log("inner", i);
}
console.log("outer", i);
}Practise this
Exercises for this lesson are in the JavaScript practice set.
Open the JavaScript playground
Related lessons
- Variables in JavaScript: let, const and varHow to declare variables in JavaScript with let, const and var, what block scope and hoisting mean, and why const is the default choice.8 min
- JavaScript Functions: Declarations, Arrows and Return ValuesHow to define and call JavaScript functions with declarations, expressions and arrow functions; parameters, default values, return and local scope.10 min
- Closures in JavaScriptWhat a JavaScript closure is, how a function keeps access to the scope it was created in, and how to use that for counters, factories and callbacks.10 min
- Loops in JavaScript: for, while, for...of and for...infor, while, do...while, for...of and for...in loops in JavaScript, with break and continue, and how to read every line of input in a loop.9 min
Frequently asked questions
Are let and const hoisted in JavaScript?
Yes, but not the way var is. The engine registers let and const names before running the block, which is why an inner declaration shadows an outer variable for the whole block, not just from its line onwards. Until the declaration line executes the variable is in the temporal dead zone and any access throws a ReferenceError, so in practice you cannot use it early.
What is the temporal dead zone?
The stretch of code from the start of a block to the line where a let, const or class declaration runs. The name exists during that stretch but is uninitialised, and reading or assigning it throws a ReferenceError. It exists so that using a variable before its declaration is a visible error rather than a silent undefined.
Does a top-level var become a property of the global object?
In a classic browser script, yes: var count = 1 at the top level creates window.count, while top-level let and const do not. In an ES module, and in a Node.js CommonJS file, top-level declarations are scoped to the module and do not touch the global object. This is one more reason to prefer let and const.
How this page was checked. Every program on it was run with Node.js 22 at build time; runs in your browser in an isolated Web Worker by the publishing checks, and the output shown is what it printed. The Run buttons execute the same code on your device; nothing you type is sent anywhere.