Difficulty: Intermediate
Scope in JavaScript determines the accessibility and lifetime of variables, functions, and objects at any point during code execution. There are three main types of scope: global scope (accessible everywhere), function scope (accessible only within the enclosing function), and block scope (accessible only within the enclosing {} block). Before ES6, JavaScript only had global and function scope via the `var` keyword. The introduction of `let` and `const` added true block scoping, which is now the standard way to declare variables.
Hoisting is JavaScript's behavior of moving declarations to the top of their enclosing scope during the compilation phase, before any code is executed. However, hoisting works differently for different declaration types, and this is where most confusion arises. `var` declarations are hoisted to the top of their function (or global) scope, but only the declaration is hoisted - not the initialization. This means a `var` variable exists from the start of its scope but has the value `undefined` until the assignment line is reached.
Function declarations are fully hoisted - both the name and the entire function body are moved to the top of the scope. This is why you can call a function declaration before it appears in the code. Function expressions (including arrow functions assigned to variables), on the other hand, follow the hoisting rules of whichever keyword they are assigned with - var, let, or const.
`let` and `const` are technically hoisted as well, but they are placed in the Temporal Dead Zone (TDZ) from the start of their block until the declaration line is reached. Accessing a let or const variable in the TDZ throws a ReferenceError. This is intentional - it catches bugs where you accidentally use a variable before declaring it, which `var` silently allows by returning undefined.
The scope chain is the mechanism JavaScript uses to resolve variable references. When you reference a variable, the engine first looks in the current scope. If it doesn't find it, it looks in the outer (parent) scope, then the next outer scope, and so on, all the way up to the global scope. If the variable is not found anywhere in the chain, a ReferenceError is thrown. This chain is determined at the time the function is defined (lexical scoping), not when it is called - which is the foundation for closures.
// Global scope
var globalVar = 'I am global';
function outer() {
// Function scope
var functionVar = 'I am function-scoped';
if (true) {
// Block scope
let blockLet = 'I am block-scoped';
var blockVar = 'I am function-scoped despite being in a block';
console.log(blockLet); // accessible here
}
console.log(blockVar); // accessible - var ignores blocks
// console.log(blockLet); // ReferenceError - let respects blocks
}
outer();
console.log(globalVar); // accessible everywhere
// console.log(functionVar); // ReferenceError - trapped in outer()
var is function-scoped - blockVar declared inside the if block is accessible throughout outer(). let is block-scoped - blockLet is only accessible within the if block. globalVar, declared with var outside any function, is accessible everywhere. functionVar is trapped inside outer() and not accessible from outside.
// var hoisting: declaration hoisted, not initialization
console.log(x); // undefined, not ReferenceError
var x = 10;
console.log(x); // 10
// What the engine actually sees:
// var x; ← declaration hoisted
// console.log(x); ← undefined
// x = 10; ← assignment stays
// console.log(x); ← 10
// Function declaration: FULLY hoisted
console.log(greet('Alice'));
function greet(name) {
return `Hello, ${name}!`;
}
// Function expression: follows var rules
console.log(typeof sayBye); // undefined, not a function
var sayBye = function(name) {
return `Bye, ${name}!`;
};
var x is hoisted but its value is undefined until line 3. The greet function declaration is fully hoisted - you can call it before it appears in code. sayBye is a function expression assigned to var, so only the var declaration is hoisted (as undefined) - calling it before the assignment would throw a TypeError.
// TDZ: from start of block to declaration line
{
// TDZ for 'value' starts here
// console.log(value); // ReferenceError: Cannot access 'value' before initialization
let value = 42; // TDZ ends here
console.log(value); // 42
}
// TDZ is per-scope, not per-file
let name = 'outer';
{
// This 'name' shadows the outer one
// TDZ starts at block opening for this inner 'name'
// console.log(name); // ReferenceError - TDZ! Not "outer"!
let name = 'inner';
console.log(name);
}
console.log(name);
Inside the block, a new let name declaration shadows the outer one. The TDZ for the inner name starts at the opening brace of the block, so even though there is an outer name variable, trying to access name before the inner declaration throws a ReferenceError. After the block, the outer name is accessible again.
const globalVal = 'global';
function outer() {
const outerVal = 'outer';
function middle() {
const middleVal = 'middle';
function inner() {
const innerVal = 'inner';
// inner can access all parent scopes
console.log(innerVal); // own scope
console.log(middleVal); // parent scope
console.log(outerVal); // grandparent scope
console.log(globalVal); // global scope
}
inner();
}
middle();
}
outer();
JavaScript resolves variables by walking up the scope chain. inner() first looks in its own scope, then middle's scope, then outer's scope, then global scope. This chain is established when the functions are defined (lexical scoping), not when they are called.
global scope, function scope, block scope, var hoisting, function hoisting, let hoisting, const hoisting, temporal dead zone, scope chain