JavaScript Variable Declarations: var, let, and const Explained

JavaScript provides three keywords for binding identifiers in a lexical environment: var, let, and const. While var served as the sole declaration mechanism from ES1 through ES5, ECMAScript 2015 (ES6) introduced let and const to eliminate subtle bugs caused by function-level variable hoisting, loop closure aliasing, and accidental global namespace pollution.

1. Function Scope and Hoisting with var

Declarations made with var are scoped to the nearest enclosing function scope (or the global execution context when declared at the top level of a non-module script). Control-flow blocks such as if, for, and while do not create a new scope for var bindings.

Variable Hoisting and Redeclaration

During the creation phase of an execution context, the JavaScript engine hoists every var declaration to the top of its enclosing function and initializes its binding to undefined. Redeclaring the same identifier with var in the same scope is silently permitted, which can unintentionally clobber existing state:

function inspectVarScope() {
  console.log(statusMessage); // undefined (hoisted and default-initialized)

  var statusMessage = 'initializing';
  if (true) {
    var statusMessage = 'active'; // Same function-scoped binding, overwritten!
  }

  console.log(statusMessage); // 'active'
}

// Classic async loop bug with var: all callbacks share a single function-scoped index
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log('var loop index:', i), 10); // Logs 3, 3, 3
}

2. Lexical Block Scope and the Temporal Dead Zone with let

The let keyword introduces true lexical block scope. Any binding declared with let is confined strictly to the nearest enclosing { ... } block—including if branches, switch cases, standalone blocks, and for loop headers.

The Temporal Dead Zone (TDZ)

Although the engine registers let bindings in the block's lexical environment upon entering the scope, it leaves them uninitialized until evaluation reaches the lexical declaration statement. Accessing the identifier before that line throws a ReferenceError—a safety region known as the Temporal Dead Zone:

function processRetryBatch(maxRetries) {
  // console.log(attempt); // ReferenceError: Cannot access 'attempt' before initialization (TDZ)
  let attempt = 0;

  // In a for-loop, let creates a fresh lexical binding for each iteration
  for (let step = 0; step < maxRetries; step++) {
    setTimeout(() => console.log('let loop step:', step), 10); // Logs 0, 1, 2
  }

  attempt += maxRetries;
  return attempt;
}

3. Binding Immutability vs. Value Mutability with const

Like let, const is block-scoped, prohibits duplicate declarations in the same scope, and enforces the Temporal Dead Zone. Additionally, const requires an explicit initializer at declaration time and creates an immutable binding: the variable identifier cannot be reassigned to point to a different memory reference.

Reference Immutability vs. Deep Object Freezing

A frequent misconception in JavaScript interviews is assuming const makes objects or arrays deeply immutable. In reality, const only locks the stack binding (the reference pointer), not the heap-allocated properties of an object. To enforce shallow or deep value immutability at runtime, pair const with Object.freeze() or TypeScript Readonly<T> types:

const MAX_POOL_CONNECTIONS = 32;
// MAX_POOL_CONNECTIONS = 64; // TypeError: Assignment to constant variable.

const runtimeConfig = {
  host: 'db.internal.webdesigner.la',
  port: 5432,
  tls: true
};

// Allowed: mutating a property on the referenced heap object
runtimeConfig.port = 6432;

// Disallowed: reassigning the binding to a new object literal
// runtimeConfig = { host: 'localhost', port: 5432, tls: false }; // TypeError

// Enforce runtime property immutability with Object.freeze
const frozenConfig = Object.freeze({ region: 'us-west-1', retries: 3 });
// In strict mode, frozenConfig.retries = 5 throws a TypeError

Architectural Comparison: var vs. let vs. const

Dimension var let const
Lexical Scope Function or Global scope Block scope ({ ... }) Block scope ({ ... })
Hoisting Behavior Hoisted and initialized to undefined Hoisted into TDZ (uninitialized) Hoisted into TDZ (uninitialized)
Same-Scope Redeclaration Permitted (silently overwrites) SyntaxError at parse time SyntaxError at parse time
Binding Reassignment Mutable binding Mutable binding Immutable binding (read-only reference)
Global Object Property Attaches to window / globalThis in scripts Does not pollute global object Does not pollute global object

Production Engineering Best Practices

  • Default to const Everywhere: Declare every variable, imported module, function expression, and configuration object with const by default. Signaling that a reference will not be rebound makes data flow easier for both human reviewers and static analyzers to reason about.
  • Reserve let for Explicit State Transitions: Use let only when an identifier genuinely requires reassignment—such as loop counters, accumulator variables, or lazy-initialized handles inside try / catch blocks.
  • Ban var via ESLint (no-var & prefer-const): Enforce "no-var": "error" and "prefer-const": "error" in your ESLint configuration to prevent legacy function-scoping defects from entering modern TypeScript and ESModule codebases.