let vs const vs var: The Complete Guide

Understanding the differences between JavaScript variable declarations and when to use each.

Quick Summary

Feature var let const
Scope Function Block Block
Hoisting Yes, initialized to undefined Yes, uninitialized (TDZ) Yes, uninitialized (TDZ)
Reassignment Yes Yes No
Redeclaration Yes No No
Creates global property Yes (at top level) No No

The short version: default to const, use let when you must reassign, and treat var as a legacy keyword you read but never write. The rest of this guide explains why — including what the engine actually does during hoisting, what the temporal dead zone is, and why var breaks closures in loops.

Hoisting: What the Engine Actually Does

"Hoisting" is often described as declarations being "moved to the top", but no code moves anywhere. JavaScript processes every scope in two phases:

  1. Instantiation — before executing a single statement, the engine scans the scope and creates a binding (a named slot in the environment record) for every declaration it finds.
  2. Execution — statements run top to bottom and assign values into those pre-created slots.

All three keywords are hoisted in the sense that their bindings exist from the start of the scope. The difference is initialization:

  • var bindings are created and immediately initialized to undefined.
  • let and const bindings are created uninitialized. Touching them before the declaration line throws a ReferenceError.
  • Function declarations are hoisted with their full body, which is why you can call them "before" they appear.
console.log(a); // undefined — var binding exists, initialized
var a = 1;

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;

The Temporal Dead Zone (TDZ)

The region between the start of a scope and the let/const declaration is the temporal dead zone. It is temporal because it is about execution time, not code position — a function defined above the declaration can legally use the variable, as long as it is called after initialization:

function logX() { console.log(x); } // references x lexically before its line
let x = 10;
logX(); // 10 — fine, x is initialized by the time this runs

TDZ surprises worth knowing:

// 1. typeof is no longer safe
typeof notDeclared;   // 'undefined' — genuinely undeclared is fine
typeof y;             // ReferenceError!
let y = 1;

// 2. Self-reference throws
let z = z + 1;        // ReferenceError — z is in its own TDZ

// 3. Default parameters have a left-to-right TDZ
function f(a = b, b = 2) {} 
f();                  // ReferenceError — b is in TDZ when a's default runs

The TDZ is a feature, not a bug: it converts "silently read undefined" — the classic var footgun — into a loud, immediate error at the exact line of the mistake.

Why var Is Considered Harmful

1. Function scope leaks out of blocks. var ignores if, for, and bare {} blocks entirely:

if (true) {
  var x = 10;
}
console.log(x); // 10 — escaped the block

2. Silent redeclaration. Declaring the same var twice is legal, so a typo or a careless merge can overwrite a variable three hundred lines away. let and const make redeclaration a SyntaxError at parse time.

3. Global object pollution. At the top level of a script, var x creates a property on globalThis (i.e. window.x in browsers). let and const create globals that live in a separate declarative record and do not attach to the global object — one less way to collide with other scripts.

4. The loop-closure bug — important enough for its own section.

Loop Closures: var vs let

The most famous JavaScript interview question is really a question about bindings:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 3, 3, 3

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 0, 1, 2

With var there is exactly one binding of i for the whole loop, hoisted to function scope. All three arrow functions close over that single slot, and by the time the timers fire, the loop has finished and the slot holds 3.

With let, the specification requires the engine to create a fresh binding for every iteration and copy the current value into it at the top of each pass. Each closure captures its own copy. Before ES6, developers simulated this with an IIFE — (function (j) { ... })(i) — purely to create a new scope per iteration.

One related subtlety: const works in for...of and for...in loops (a fresh, never-reassigned binding per iteration) but throws a TypeError in a classic for (const i = 0; i < 3; i++) because i++ is an assignment.

const: Constant Binding, Not Constant Value

const freezes the binding — the name-to-value association — not the value itself:

const user = { name: 'John' };
user.name = 'Jane';   // OK — mutating the object
// user = {};         // TypeError — reassigning the binding

const arr = [1, 2, 3];
arr.push(4);          // OK
// arr = [];          // TypeError

If you need actual immutability, use Object.freeze(obj) — and remember it is shallow; nested objects remain mutable unless frozen recursively. For compile-time guarantees, TypeScript's readonly and as const are more practical than runtime freezing.

A useful side effect of "const by default": when you do see let in a well-kept codebase, it is a signal that the value changes somewhere — the keyword itself documents intent.

Best Practices

  • Default to const. It costs nothing and catches accidental reassignments immediately.
  • Use let only when reassignment is genuinely part of the logic: counters, accumulators, retry state.
  • Never write var in new code. There is no modern use case that let/const plus block scoping does not cover better.
  • Automate it. Enable ESLint's no-var and prefer-const rules; both are auto-fixable, so migrating an old file is one command.
  • Declare close to first use. Block scoping only helps if you keep scopes small; declaring everything at the top of a function recreates var-era readability problems voluntarily.

Conclusion

var hoists to function scope and initializes to undefined, leaks from blocks, redeclares silently, and shares one binding across loop iterations. let and const hoist as uninitialized bindings guarded by the TDZ, respect block scope, forbid redeclaration, and create per-iteration bindings in loops. Use const by default, let for real reassignment, and leave var to the archives.