<< Home

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.

💡 A clear grasp of variable scope and lifecycle is one of the biggest steps toward mastering JavaScript’s execution model. It directly impacts how data flows through functions, modules, and asynchronous operations.

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
}
⚠️ Because 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.

💡 Always prefer 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
⚠️ Use 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.

💡 Declaring variables as 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().

💡 Lexical scope is determined at the time the code is written, not when it executes. This predictability makes it easier to reason about variable access and helps avoid unintended name collisions.

Global, function, and block scope

JavaScript has three main kinds of scope:

{
  let blockScoped = "visible only here";
  var functionScoped = "visible outside block";
}

console.log(functionScoped);  // works
console.log(blockScoped);     // ReferenceError
⚠️ Using 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.

💡 Use meaningful, context-specific variable names to avoid accidental shadowing. Tools like linters can also help detect it automatically.

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.

⚠️ Only declarations are hoisted, not initializations. Accessing a variable before it has been assigned still yields 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;
}
💡 Although 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.

⚠️ To avoid confusion, always declare functions before using them, even though declarations are technically hoisted. This improves code readability and avoids unintended behavior when refactoring.

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.

⚠️ Accessing a 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
}
💡 Think of the TDZ as JavaScript’s safeguard against “reading before definition.” It encourages clear, top-to-bottom logic and avoids unpredictable results that plagued older 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.

💡 A closure is not a special type of object or structure; it’s simply the natural result of how JavaScript’s lexical scoping works.

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 capture variables, not values. If the variable later changes, the closure will see the updated value, not a snapshot.

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.

💡 Mastering closures unlocks a deep understanding of how JavaScript manages memory, scope, and state. It’s the key to writing elegant, modular, and reusable code.