What Is Scope?
Scope determines where variables are accessible in your code. When you declare a variable, scope decides which parts of your program can see and use it.
function greet() {
const message = 'Hello'; // message exists only inside greet()
console.log(message); // works
}
greet();
console.log(message); // ReferenceError: message is not defined
JavaScript has three types of scope:
Scope Type Created By Accessible Where
──────────────────────────────────────────────────────────────────
Global top-level code everywhere
Function function declaration/expr inside the function
Block { } with let/const inside the block
Global Scope
Variables declared outside any function or block exist in the global scope. They are accessible from anywhere in your program.
// Global scope
const appName = 'MyApp';
let userCount = 0;
function showInfo() {
// Can access global variables
console.log(appName); // 'MyApp'
console.log(userCount); // 0
}
if (true) {
// Can access global variables
console.log(appName); // 'MyApp'
}
Global Variables and the Global Object
In browsers, var declarations and function declarations at the top level become properties of the window object. let and const do not.
var globalVar = 'I am on window';
let globalLet = 'I am NOT on window';
const globalConst = 'I am NOT on window';
console.log(window.globalVar); // 'I am on window'
console.log(window.globalLet); // undefined
console.log(window.globalConst); // undefined
This is one of many reasons to avoid var — it pollutes the global object.
Why Global Variables Are Dangerous
// File: auth.js
var user = 'admin';
// File: analytics.js (loaded later)
var user = 'anonymous'; // silently overwrites auth.js's user variable
// File: auth.js (checks user later)
console.log(user); // 'anonymous' — BUG
Global variables create naming collisions, make code hard to reason about, and cannot be garbage collected. Minimize them.
Function Scope
Variables declared with var, let, or const inside a function are scoped to that function. They cannot be accessed from outside.
function calculateTax(income) {
const taxRate = 0.3; // function-scoped
var taxAmount = income * taxRate; // function-scoped
let result = income - taxAmount; // function-scoped
return result;
}
calculateTax(100000);
// console.log(taxRate); // ReferenceError
// console.log(taxAmount); // ReferenceError
// console.log(result); // ReferenceError
Nested Functions and Scope Chain
Functions can access variables from their parent scope. This is called the scope chain.
const global = 'I am global';
function outer() {
const outerVar = 'I am outer';
function middle() {
const middleVar = 'I am middle';
function inner() {
const innerVar = 'I am inner';
// inner can access everything above it
console.log(innerVar); // I am inner
console.log(middleVar); // I am middle
console.log(outerVar); // I am outer
console.log(global); // I am global
}
inner();
// console.log(innerVar); // ReferenceError
}
middle();
// console.log(middleVar); // ReferenceError
}
The lookup works from inside out:
inner() scope
│ not found? look up
v
middle() scope
│ not found? look up
v
outer() scope
│ not found? look up
v
Global scope
│ not found?
v
ReferenceError
Block Scope
let and const are block-scoped. A block is any code wrapped in curly braces {} — if/else, for, while, or even a standalone block.
if (true) {
let blockLet = 'visible only in this block';
const blockConst = 'also only in this block';
var blockVar = 'visible outside the block!';
}
// console.log(blockLet); // ReferenceError
// console.log(blockConst); // ReferenceError
console.log(blockVar); // 'visible outside the block!'
Block Scope in Loops
This is where block scope makes the biggest practical difference.
for (let i = 0; i < 3; i++) {
// Each iteration gets its own i
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
for (var i = 0; i < 3; i++) {
// All iterations share the same i
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
With let, each iteration of the loop creates a new binding. With var, there is only one i shared across all iterations.
let in a loop:
Iteration 0: let i = 0 ─> callback captures i=0
Iteration 1: let i = 1 ─> callback captures i=1
Iteration 2: let i = 2 ─> callback captures i=2
var in a loop:
var i (one variable for all iterations)
Iteration 0: i = 0 ─> callback captures reference to i
Iteration 1: i = 1 ─> callback captures reference to i
Iteration 2: i = 2 ─> callback captures reference to i
Loop ends: i = 3 ─> all callbacks see i = 3
Standalone Blocks
You can use a standalone block to create a limited scope.
{
const temp = computeExpensiveValue();
processValue(temp);
}
// temp is gone — cannot be accessed here
// clean scope, no lingering variables
var vs let vs const
Here is a comprehensive comparison:
Feature var let const
──────────────────────────────────────────────────────────────────
Scope function block block
Hoisting yes (undefined) yes (TDZ) yes (TDZ)
Re-declaration allowed not allowed not allowed
Re-assignment allowed allowed not allowed
Global object prop yes no no
Loop binding shared per-iteration per-iteration
var — The Legacy Declaration
// var is function-scoped, not block-scoped
function example() {
if (true) {
var x = 10;
}
console.log(x); // 10 (still accessible — var ignores blocks)
}
// var allows re-declaration
var name = 'Alice';
var name = 'Bob'; // no error — silently overwrites
console.log(name); // 'Bob'
// var is hoisted with value undefined
console.log(y); // undefined (not ReferenceError)
var y = 5;
let — The Modern Variable
// let is block-scoped
function example() {
if (true) {
let x = 10;
}
// console.log(x); // ReferenceError
}
// let does not allow re-declaration in same scope
let name = 'Alice';
// let name = 'Bob'; // SyntaxError: Identifier 'name' has already been declared
// let allows re-assignment
let count = 0;
count = 1; // fine
// let is hoisted but not initialized (TDZ)
// console.log(z); // ReferenceError: Cannot access 'z' before initialization
let z = 5;
const — The Constant Binding
// const must be initialized at declaration
const PI = 3.14159;
// const UNSET; // SyntaxError: Missing initializer in const declaration
// const cannot be re-assigned
// PI = 3.14; // TypeError: Assignment to constant variable
// BUT: const objects/arrays CAN be mutated
const user = { name: 'Alice' };
user.name = 'Bob'; // fine — mutating the object, not re-assigning the binding
user.age = 30; // fine
const items = [1, 2, 3];
items.push(4); // fine — [1, 2, 3, 4]
// items = [5, 6]; // TypeError — re-assignment not allowed
// To prevent mutation, use Object.freeze
const frozen = Object.freeze({ name: 'Alice' });
frozen.name = 'Bob'; // silently fails (or throws in strict mode)
console.log(frozen.name); // 'Alice'
Which Should You Use?
Guideline Use
──────────────────────────────────────────────
Value never changes const
Value needs re-assignment let
Legacy code / specific reason var (avoid)
Default to const. Use let when you need to re-assign. Avoid var entirely in new code.
Hoisting
Hoisting is JavaScript's behavior of moving declarations to the top of their scope during the compilation phase. The declarations are moved, but the assignments stay in place.
var Hoisting
var declarations are hoisted and initialized to undefined.
console.log(x); // undefined (not ReferenceError)
var x = 5;
console.log(x); // 5
The engine processes this as:
// What JavaScript actually does:
var x; // declaration hoisted to top, initialized to undefined
console.log(x); // undefined
x = 5; // assignment stays in place
console.log(x); // 5
A more complex example:
function init() {
console.log(status); // undefined
console.log(count); // undefined
var status = 'ready';
var count = 0;
console.log(status); // 'ready'
console.log(count); // 0
}
Processed as:
function init() {
var status; // hoisted
var count; // hoisted
console.log(status); // undefined
console.log(count); // undefined
status = 'ready'; // assignment in place
count = 0; // assignment in place
console.log(status); // 'ready'
console.log(count); // 0
}
let and const Hoisting (Temporal Dead Zone)
let and const are also hoisted, but they are not initialized. Accessing them before the declaration throws a ReferenceError. The region between the start of the scope and the declaration is called the Temporal Dead Zone (TDZ).
{
// TDZ starts for x
// console.log(x); // ReferenceError: Cannot access 'x' before initialization
// TDZ continues...
// console.log(x); // still ReferenceError
let x = 10; // TDZ ends, x is now initialized
console.log(x); // 10
}
Visualized:
{
┌─── TDZ for x ───┐
│ │ x exists but cannot be accessed
│ console.log(x) │ -> ReferenceError
│ │
└──────────────────┘
let x = 10; x is initialized here
console.log(x); works fine — 10
}
TDZ Is Temporal, Not Spatial
The TDZ is based on execution order, not position in code.
function logValue() {
console.log(value); // no error when function is DEFINED
}
let value = 42;
logValue(); // 42 — works because value is initialized when function is CALLED
// But this throws:
function broken() {
console.log(x);
let x = 10; // x's TDZ covers the console.log line
}
broken(); // ReferenceError
Function Hoisting
Function declarations are fully hoisted — both the name and the body.
// Works — function declarations are fully hoisted
greet(); // 'Hello!'
function greet() {
console.log('Hello!');
}
Function expressions follow the rules of their variable keyword.
// var function expression — hoisted as undefined
// sayHi(); // TypeError: sayHi is not a function (undefined is not callable)
var sayHi = function () {
console.log('Hi!');
};
// let/const function expression — TDZ applies
// sayBye(); // ReferenceError: Cannot access 'sayBye' before initialization
const sayBye = function () {
console.log('Bye!');
};
// Arrow functions follow the same rules
// add(1, 2); // ReferenceError
const add = (a, b) => a + b;
Function vs var Hoisting Priority
When a function declaration and a var declaration have the same name, the function declaration wins.
console.log(typeof foo); // 'function' (not 'undefined')
var foo = 'string';
function foo() {
return 'function';
}
console.log(typeof foo); // 'string' (var assignment runs after)
The engine processes this as:
// Hoisting phase:
function foo() { return 'function'; } // function hoisted first
var foo; // var re-declaration ignored (function already defined)
// Execution phase:
console.log(typeof foo); // 'function'
foo = 'string'; // assignment overwrites
console.log(typeof foo); // 'string'
Scope in Practice
if/else and Scope
const score = 85;
if (score >= 90) {
const grade = 'A';
var passed = true;
} else if (score >= 80) {
const grade = 'B'; // different block, different variable
var passed = true; // same var — function/global scope
} else {
const grade = 'F';
var passed = false;
}
// console.log(grade); // ReferenceError — block-scoped
console.log(passed); // true — var leaks out of block
switch and Scope
Switch cases share the same block scope by default. Use explicit blocks to isolate them.
// BUG: shared scope
switch (action) {
case 'create':
let result = createItem(); // declared in switch block
break;
case 'delete':
let result = deleteItem(); // SyntaxError: already declared
break;
}
// FIX: use blocks
switch (action) {
case 'create': {
let result = createItem(); // scoped to this block
break;
}
case 'delete': {
let result = deleteItem(); // different block, no conflict
break;
}
}
for...in and for...of
const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
console.log(key); // 'a', 'b', 'c'
}
// console.log(key); // ReferenceError (const is block-scoped)
const arr = [10, 20, 30];
for (const value of arr) {
console.log(value); // 10, 20, 30
}
// console.log(value); // ReferenceError
Closures and Scope
Closures are a direct consequence of scope rules. A function retains access to its outer scope even after the outer function returns.
function createMultiplier(factor) {
// factor is in createMultiplier's scope
return function (number) {
return number * factor; // inner function closes over factor
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10 — factor is 2
console.log(triple(5)); // 15 — factor is 3
Each call to createMultiplier creates a new scope with its own factor. The returned function remembers that scope. (See the closures article for a deep dive.)
Shadowing
When a variable in an inner scope has the same name as one in an outer scope, the inner variable shadows the outer one.
const x = 'global';
function test() {
const x = 'function'; // shadows global x
console.log(x); // 'function'
if (true) {
const x = 'block'; // shadows function x
console.log(x); // 'block'
}
console.log(x); // 'function' (block x is gone)
}
test();
console.log(x); // 'global' (never modified)
Illegal Shadowing
You cannot shadow a let with a var in the same function.
function test() {
let x = 10;
if (true) {
// var x = 20; // SyntaxError: Identifier 'x' has already been declared
let x = 20; // fine — block scope shadows
console.log(x); // 20
}
}
But you CAN shadow a var with a let:
function test() {
var x = 10;
if (true) {
let x = 20; // fine — let creates a new block-scoped binding
console.log(x); // 20
}
console.log(x); // 10
}
Common Mistakes
Mistake 1: Accidental Global Variables
function processData() {
// BUG: missing declaration keyword — creates global variable
result = 42;
}
processData();
console.log(result); // 42 — leaked to global scope
// FIX: always use const/let
function processData() {
const result = 42;
}
In strict mode, assigning to an undeclared variable throws a ReferenceError.
'use strict';
function processData() {
result = 42; // ReferenceError: result is not defined
}
Mistake 2: var in Loops
// BUG: all buttons show the same index
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function () {
console.log(`Button ${i} clicked`); // always buttons.length
});
}
// FIX: use let
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function () {
console.log(`Button ${i} clicked`); // correct index
});
}
Mistake 3: Hoisting Surprises
// BUG: developer expects x to be global value
var x = 'global';
function surprise() {
console.log(x); // undefined (not 'global'!)
var x = 'local';
console.log(x); // 'local'
}
surprise();
The var x inside surprise() is hoisted to the top of the function, shadowing the global x. The first console.log sees the hoisted (but uninitialized) local x.
// What the engine sees:
function surprise() {
var x; // hoisted — shadows global x
console.log(x); // undefined
x = 'local';
console.log(x); // 'local'
}
Mistake 4: Assuming const Means Immutable
const config = {
debug: false,
version: '1.0',
};
// This works — you are mutating the object, not the binding
config.debug = true;
config.newProp = 'added';
// This throws — you are trying to re-assign the binding
// config = {}; // TypeError
// For true immutability, use Object.freeze
const frozenConfig = Object.freeze({
debug: false,
version: '1.0',
});
frozenConfig.debug = true; // silently fails
console.log(frozenConfig.debug); // false
Module Scope
ES modules create their own scope. Variables declared at the top level of a module are not global — they are module-scoped.
// module-a.js
const secret = 'only visible in this module';
export const publicValue = 'visible to importers';
// module-b.js
import { publicValue } from './module-a.js';
console.log(publicValue); // 'visible to importers'
// console.log(secret); // ReferenceError — not exported
This is one of the biggest advantages of modules over scripts. Each module has its own scope, so naming collisions between files become impossible.
Scope Summary Diagram
┌──────────────────────────────────────────────────────┐
│ GLOBAL SCOPE │
│ var globalVar (on window in browser) │
│ let globalLet (not on window) │
│ const globalConst │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ FUNCTION SCOPE │ │
│ │ var, let, const — all function-scoped │ │
│ │ │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ BLOCK SCOPE (if, for, {}) │ │ │
│ │ │ let, const — block-scoped │ │ │
│ │ │ var — leaks to function scope │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Key Takeaways
- JavaScript has three scope levels: global, function, and block
varis function-scoped;letandconstare block-scoped- Default to
const, useletwhen re-assignment is needed, avoidvar varis hoisted and initialized toundefined;letandconstare hoisted but enter the Temporal Dead Zone- Function declarations are fully hoisted (name + body); function expressions follow their variable's rules
- The scope chain looks up from inner scopes to outer scopes until it finds the variable or reaches global
- Shadowing lets inner scopes override outer variable names without affecting the outer variable
- Accidental globals are created when you assign to undeclared variables — use strict mode to catch them
- ES modules create their own scope — prefer modules over scripts to avoid global pollution
- Understanding scope is the foundation for understanding closures,
thisbinding, and memory management