Chapter 4: Variables and Scope
Variables are the foundation of any program, serving as named references to values stored in memory. In JavaScript, variables are not simply containers for data. Their behavior depends on how and where they are declared. The language provides three distinct keywords for variable declaration (var, let, and const), each with different scope rules and lifetimes that affect how code executes and interacts across functions and blocks.
Scope determines where variables are visible and accessible within your program. JavaScript uses a model called lexical scoping, which ties variable visibility to the structure of the code as written. Understanding how scope works is essential for writing predictable, maintainable programs and avoiding subtle bugs caused by shadowing or unintended reuse of variable names.
This chapter explores the evolution of JavaScript’s variable system from the early days of var to the modern, block-scoped let and const. It explains how declarations are processed by the interpreter through a mechanism known as hoisting, why the temporal dead zone exists, and how closures form when inner functions retain access to variables from their outer scope.
var, let, and const
JavaScript provides three ways to declare variables: var, let, and const. Each differs in how it handles scope, reassignment, and hoisting. Understanding these differences is essential to writing safe, predictable code. Although all three keywords create variables, only let and const follow modern block-scoping rules introduced in ECMAScript 2015 (ES6). The older var keyword uses function scope and behaves differently in several subtle ways.
var
The var keyword declares a variable that is function-scoped (or globally scoped if declared outside a function). Variables declared with var are hoisted to the top of their scope and initialized with undefined before any code runs. This can cause unexpected results if you attempt to use them before their declaration appears.
function example() {
console.log(a); // undefined (hoisted)
var a = 10;
console.log(a); // 10
}
var does not respect block boundaries, it can leak outside of loops and conditionals. Use let or const instead for predictable scoping.
let
The let keyword declares a variable with block scope, meaning it is only accessible within the nearest set of curly braces ({…}). Unlike var, let declarations are not initialized until the line of execution reaches them, creating what is known as the temporal dead zone (covered later in this chapter).
{
let x = 5;
console.log(x); // 5
}
console.log(x); // ReferenceError: x is not defined
Variables declared with let can be reassigned, making them suitable for values that change over time, such as counters or mutable state.
let over var unless you have a specific reason to rely on function scope. It helps prevent accidental overwriting and makes scope boundaries explicit.
const
The const keyword declares a block-scoped variable that cannot be reassigned after its initial definition. It is ideal for values that should remain constant throughout a program, such as configuration data or references that should not be replaced.
const pi = 3.14159;
pi = 3; // TypeError: Assignment to constant variable
However, const does not make objects or arrays immutable — it only prevents reassignment of the variable binding itself. The contents of a referenced object can still change.
const user = { name: "Robin" };
user.name = "Alex"; // allowed
console.log(user.name); // Alex
Object.freeze() if you need to prevent changes to the contents of an object or array, not just its reference.
Choosing the right declaration
In modern JavaScript, the general rule is simple: use const by default, and let only when reassignment is necessary. Avoid var entirely except when maintaining legacy code or working in older environments that do not support block-scoped declarations.
const first encourages clarity and helps catch unintended mutations early, making your programs more reliable.
Lexical scope
JavaScript uses a lexical scoping model, meaning that the scope of a variable is determined by its position in the written code, not by where or when it is executed. In other words, the structure of your program defines which variables are visible in which parts of the code. This is a fundamental concept that underlies closures, module systems, and modern JavaScript’s block-scoped declarations.
How lexical scope works
When JavaScript code runs, it creates a series of nested scopes, forming what is known as a scope chain. Each function or block introduces a new scope. When you reference a variable, JavaScript looks for it first in the current scope, then moves outward through the parent scopes until it finds a matching declaration, or throws a ReferenceError if it doesn’t find one.
let globalVar = "global";
function outer() {
let outerVar = "outer";
function inner() {
let innerVar = "inner";
console.log(globalVar); // found in global scope
console.log(outerVar); // found in outer scope
console.log(innerVar); // found in inner scope
}
inner();
}
outer();
In this example, each level of the nested function chain has access to variables declared in the levels above it, but not below. The inner() function can see outerVar and globalVar, but the reverse is not true because the outer scopes cannot access variables defined within inner().
Global, function, and block scope
JavaScript has three main kinds of scope:
- Global scope: variables declared outside any function or block, accessible everywhere.
- Function scope: variables declared inside a function (using
var) are visible only within that function. - Block scope: variables declared with
letorconstare limited to the nearest block ({…}).
{
let blockScoped = "visible only here";
var functionScoped = "visible outside block";
}
console.log(functionScoped); // works
console.log(blockScoped); // ReferenceError
var inside a block does not restrict it to that block, which can lead to leaks into the containing scope. Always use let or const for block-local variables.
Nested scopes and shadowing
When a variable in an inner scope shares the same name as one in an outer scope, the inner variable shadows the outer one within its scope. This allows for local redefinition without altering the outer variable, but can also cause confusion if overused.
let x = 10;
function test() {
let x = 5; // shadows outer x
console.log(x); // 5
}
test();
console.log(x); // 10
Variable shadowing is often used deliberately in small, self-contained scopes, but in larger codebases it can obscure meaning and lead to mistakes if the same identifier is reused unintentionally.
Hoisting explained
Hoisting is JavaScript’s process of moving variable and function declarations to the top of their scope during compilation. This means that you can reference certain identifiers before their declaration appears in the source code. However, the behavior differs between var, let, const, and function declarations, and misunderstanding these differences can lead to confusing results.
What hoisting really means
When a JavaScript file or function is executed, the interpreter performs two passes over the code. In the first pass (the creation phase), all variable and function declarations are registered in memory. In the second pass (the execution phase), the code runs line by line. This is what allows you to call a function before it is defined, or access a variable that appears later in the code, although the value may not yet be initialized.
console.log(x); // undefined
var x = 5;
console.log(x); // 5
In this example, the declaration var x is hoisted to the top of its scope, but the assignment (x = 5) is not. This is why the first console.log shows undefined instead of throwing an error.
undefined (for var) or throws a ReferenceError (for let and const).
Hoisting with var
All var declarations are hoisted to the top of their enclosing function or global scope and automatically initialized to undefined. This can lead to unexpected results if you assume variable declarations occur in order.
function demo() {
console.log(a); // undefined
var a = 10;
console.log(a); // 10
}
Internally, JavaScript treats the example above as if it were written like this:
function demo() {
var a;
console.log(a);
a = 10;
console.log(a);
}
Hoisting with let and const
Variables declared with let and const are also hoisted to the top of their block, but unlike var, they are not automatically initialized. Accessing them before their declaration results in a ReferenceError. The period between the start of the block and the actual declaration is called the temporal dead zone (covered next).
{
console.log(value); // ReferenceError
let value = 42;
}
let and const are technically hoisted, it’s clearer to think of them as unavailable until their declaration line is reached.
Hoisting and functions
Function declarations are fully hoisted, including their definitions. This means you can safely call a function before its declaration in the code. Function expressions, on the other hand, follow variable hoisting rules depending on whether they use var, let, or const.
sayHello(); // Works fine
function sayHello() {
console.log("Hello!");
}
But if the function is defined as an expression, it behaves differently:
sayHi(); // TypeError: sayHi is not a function
var sayHi = function() {
console.log("Hi!");
};
Here, sayHi is hoisted like any other var variable and initialized to undefined, so attempting to call it before assignment fails.
Temporal dead zone
The temporal dead zone (TDZ) refers to the period between entering a block and the point where a let or const variable is declared. During this time, the variable exists in memory but cannot be accessed. Attempting to use it results in a ReferenceError. The TDZ enforces safer coding practices by preventing access to variables before they are properly initialized.
How the TDZ works
When JavaScript begins executing a block, it sets up space for all variables declared with let and const but does not initialize them. They remain in the temporal dead zone until execution reaches their declaration line. This ensures that no variable can be read or written before it has been explicitly created.
{
// TDZ begins
console.log(a); // ReferenceError
let a = 5; // TDZ ends here
console.log(a); // 5
}
Unlike var (which initializes to undefined when hoisted), let and const provide stricter guarantees about variable use. This helps prevent bugs caused by uninitialized or shadowed variables.
let or const variable before its declaration always throws a ReferenceError, even if the same name exists in an outer scope.
Example with shadowing
The TDZ also applies when an inner block redeclares a variable name already used in an outer scope. Within the TDZ, the new declaration temporarily hides (or “shadows”) the outer variable, even before it becomes initialized.
let value = "outer";
{
// TDZ for inner 'value' starts
// console.log(value); // ReferenceError
let value = "inner";
console.log(value); // "inner"
}
This rule ensures that variables behave consistently according to their lexical scope and that no code can accidentally read a variable before its valid initialization point.
const and the TDZ
Because const variables must be initialized at declaration, they remain in the TDZ until the exact moment they are defined. After initialization, they cannot be reassigned, but their presence in the TDZ still prevents access beforehand.
{
// TDZ active
// console.log(MAX); // ReferenceError
const MAX = 100;
console.log(MAX); // 100
}
var-based code.
Introduction to closures
A closure is one of JavaScript’s most powerful and defining features. It occurs when a function retains access to variables from its outer (enclosing) scope even after that outer function has finished executing. Closures enable functions to “remember” their environment, making them essential for encapsulation, data privacy, and many functional programming techniques.
How closures form
Whenever a function is created, it captures the variables that were in scope at the time of its creation. This captured environment travels with the function wherever it goes, even if the original scope has already been exited. The function can still access and modify those variables later.
function makeCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
In this example, the inner function forms a closure over the variable count, keeping it alive between calls. Each invocation of makeCounter() creates a new, independent closure with its own private count.
Practical uses of closures
Closures make it possible to write functions that preserve state without relying on global variables. They are commonly used for data privacy, event handlers, iterators, and factory functions that generate customized behavior.
function greeter(name) {
return function() {
console.log(`Hello, ${name}!`);
};
}
const greetAlice = greeter("Alice");
const greetBob = greeter("Bob");
greetAlice(); // Hello, Alice!
greetBob(); // Hello, Bob!
Here, each returned function remembers its own name value. Even though greeter() has finished executing, the inner function continues to reference its closed-over variables.
Common closure pitfalls
Because closures retain references to variables, they can also keep memory alive longer than expected. Careless use of closures (especially inside loops or long-lived contexts) can lead to unintended side effects or memory leaks if variables are not released when no longer needed.
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// Outputs: 3, 3, 3
Each function above closes over the same i from the outer scope, which has already reached 3 by the time the callbacks execute. Using let creates a new binding for each iteration, fixing the issue.
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// Outputs: 0, 1, 2
Closures and scope
Closures demonstrate how JavaScript’s lexical scoping model persists beyond function execution. The inner function retains a live link to variables from its defining environment, not just a copy. This makes closures an essential concept for understanding asynchronous code, callbacks, and functional programming patterns.