Scope is the set of rules that decides where a variable name can be used. Most "x is not defined" errors and many confusing bugs come from misreading scope, so this lesson makes the rules explicit. It also explains hoisting, the reason some names exist before the line that declares them.
Three kinds of scope
JavaScript has three scopes you meet every day.
| Scope | Created by | Variables that live there |
|---|---|---|
| Global | The script or module itself | Declarations at the top level |
| Function | Every function call | Parameters, and anything declared inside the function |
| Block | Any { } block: if, for, while, a bare block | let, const and class declared inside the block |
A variable can be read in the scope where it is declared and in every scope nested inside it. It cannot be read from outside.
const site = 'Geeks2Smart'; // global scope
function greet(name) { // name: function scope
const greeting = 'Hello'; // function scope
if (name) {
const message = `${greeting}, ${name}, welcome to ${site}`; // block scope
console.log(message);
}
// console.log(message); ReferenceError: message is not defined
}
greet('Asha');
// → Hello, Asha, welcome to Geeks2Smart
Inside the if block, message can see greeting (one level out) and site (two levels out). Outside the block, message does not exist.
The scope chain
When JavaScript meets a name, it looks in the current scope first, then the enclosing scope, and so on out to the global scope. This path is the scope chain. The first match wins, and if nothing matches you get a ReferenceError.
Because the first match wins, an inner variable with the same name shadows the outer one:
const level = 'outer';
function show() {
const level = 'inner';
console.log(level);
}
show(); // → inner
console.log(level); // → outer
Shadowing is legal but makes code harder to read. Prefer distinct names.
Scope is decided by where the code is written, not where it is called from. This is called lexical scope, and it is the foundation of closures in the next lesson.
const colour = 'blue';
function printColour() {
console.log(colour);
}
function run() {
const colour = 'red';
printColour();
}
run(); // → blue
printColour was written next to the global colour, so that is the one it sees, even though run calls it.
Blocks and loops
Each loop iteration with let gets its own fresh binding. That matters whenever a function created inside a loop runs later:
for (let i = 0; i < 3; i += 1) {
setTimeout(() => console.log(i), 0);
}
// → 0
// → 1
// → 2
Each callback remembers its own i. You will see shortly what happens with var instead.
Hoisting
Before any code in a scope runs, JavaScript scans the scope and registers every declaration in it. This is called hoisting. The names exist from the top of the scope, but what you can do with them early depends on how they were declared.
| Declaration | Registered at the top? | Value before its line runs |
|---|---|---|
function name() {} | Yes | The whole function, ready to call |
var | Yes | undefined |
let, const, class | Yes | None: reading it throws a ReferenceError |
Function declarations
Function declarations are hoisted with their body, so you can call them above the line where they appear:
console.log(double(4)); // → 8
function double(n) {
return n * 2;
}
This lets you put the main logic at the top of a file and helper functions below it. Function expressions and arrow functions assigned to const follow the const rules instead:
console.log(triple(4));
// ReferenceError: Cannot access 'triple' before initialization
const triple = (n) => n * 3;
The temporal dead zone
let, const and class are hoisted too, but they are not initialised until their declaration line runs. The stretch of code between the start of the scope and that line is the temporal dead zone (TDZ). Touching the variable inside it throws:
function checkout() {
console.log(total);
// ReferenceError: Cannot access 'total' before initialization
const total = 42;
}
checkout();
The error message tells you the variable exists but is not ready yet. Compare the message when a name is not declared anywhere:
console.log(nothingHere);
// ReferenceError: nothingHere is not defined
The TDZ also explains this surprise, where the inner declaration shadows the outer one for the whole block, including the lines above it:
const price = 10;
{
console.log(price);
// ReferenceError: Cannot access 'price' before initialization
const price = 20;
}
It is "temporal" because it is about time, not position. A function may mention a let variable declared later, as long as the function is not called until after the declaration has run:
function report() {
console.log(count);
}
let count = 5;
report(); // → 5
The TDZ is a feature. It turns a silent bug, reading a value before it is set, into a loud error on the exact line.
Why var is avoided
var is the original way to declare variables. You will meet it in older code and tutorials, so you need to recognise its behaviour, but you should not write it. It differs from let in three ways, and each one causes bugs.
1. var ignores blocks. It is scoped to the whole function (or the global scope), not the block:
function test() {
if (true) {
var leaked = 'still here';
}
console.log(leaked);
}
test(); // → still here
2. var hoists as undefined. Reading it early gives undefined instead of an error, so mistakes go unnoticed:
console.log(score); // → undefined
var score = 10;
3. One var is shared by the whole loop. Here every callback sees the same i, which has reached 3 by the time they run:
for (var i = 0; i < 3; i += 1) {
setTimeout(() => console.log(i), 0);
}
// → 3
// → 3
// → 3
At the top level of a classic script, var also creates a property on the global object (window in browsers), where it can collide with other scripts. let and const do not.
Use const by default and let when you must reassign. With these two, scope matches the braces you can see, and early access fails loudly.
Global scope and modules
Top-level declarations in a classic <script> are shared by every classic script on the page. Two files that both declare const count at the top level will clash with a SyntaxError. ES modules, covered in lesson 34, fix this: each module has its own top-level scope, and you share values deliberately with export and import.
Until then, keep the global scope small. Wrap related code in functions so most variables live in function or block scope.
Summary
- Scope decides where a name can be used: global, function or block.
- Lookup walks the scope chain outwards from where the code is written; the first match wins.
- Hoisting registers declarations before code runs. Function declarations are usable early;
varisundefinedearly. let,constandclassthrow aReferenceErrorin the temporal dead zone before their line runs.- Avoid
var: it ignores blocks, hides early reads and shares one binding across loop iterations.