Three keywords. One purpose: declaring variables. Yet they behave so differently that choosing the wrong one creates bugs that are genuinely hard to track down. This isn't about style — it's about how JavaScript's execution engine treats each declaration.
The Quick Reference
| | var | let | const | |---|---|---|---| | Scope | Function | Block | Block | | Hoisting | Yes, initialized to undefined | Yes, but in TDZ | Yes, but in TDZ | | Reassignable | Yes | Yes | No | | Re-declarable | Yes | No | No | | Global property | Yes (window.x) | No | No | | Added in | ES1 (1995) | ES6 (2015) | ES6 (2015) |
Now let's understand why each row is the way it is.
var — Function Scoped, Always Initialized
var was JavaScript's only variable keyword for the first 20 years. It has two behaviors that seem bizarre today but made sense in the original design:
It is function-scoped, not block-scoped:
function example() {
if (true) {
var x = 10 // 'var' ignores the if-block boundary
}
console.log(x) // 10 — x leaked out of the block
}
for (var i = 0; i < 3; i++) { // i exists here } console.log(i) // 3 — i leaked out of the loop
var only respects one boundary: functions. Anything inside if, for, while, or bare {} blocks is invisible to var. The variable belongs to the nearest enclosing function, or the global scope if there is none.
It is hoisted and initialized to undefined:
console.log(name) // undefined — no error
var name = 'Alice'
console.log(name) // 'Alice'
JavaScript processes var name before any code runs, setting it to undefined. The assignment (= 'Alice') happens when execution reaches that line. This is why you get undefined instead of a ReferenceError — the variable exists, it just hasn't been assigned yet.
It can be re-declared without error:
var user = 'Alice'
// ... 200 lines of code ...
var user = 'Bob' // no error — silently overwrites
console.log(user) // 'Bob'
In large files this is actively dangerous. Re-declaring var is silent — you get no warning that you just replaced a variable that might have been used throughout the file.
On the global scope, var creates window properties:
var config = { env: 'production' }
window.config // { env: 'production' } — now a global property
This means var declarations in scripts can accidentally overwrite browser APIs or collide with third-party scripts.
let — Block Scoped, TDZ Protected
let fixes the two core problems with var: scope leakage and silent initialization.
It respects block boundaries:
function example() {
if (true) {
let x = 10 // x is scoped to this if-block
}
console.log(x) // ReferenceError: x is not defined
}
for (let i = 0; i < 3; i++) { // i is scoped to the loop block // Each iteration gets its OWN i binding } console.log(i) // ReferenceError
Each loop iteration with let gets a completely separate binding — which is why the classic setTimeout-in-loop bug disappears with let:
// var — one shared i, all callbacks see final value
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0) // 3, 3, 3
}
// let — new i per iteration, each callback captures its own for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0) // 0, 1, 2 }
It is hoisted but in the Temporal Dead Zone (TDZ):
console.log(name) // ReferenceError: Cannot access 'name' before initialization
let name = 'Alice'
let is hoisted (the engine knows it exists), but it is not initialized until the declaration line executes. Between the start of the block and the declaration, it is in the TDZ — any access throws a ReferenceError. This is intentional: use-before-declaration is always a bug, and a loud error is better than a silent undefined.
It cannot be re-declared in the same scope:
let user = 'Alice'
let user = 'Bob' // SyntaxError: Identifier 'user' has already been declared
Re-declaration throws at parse time, before any code runs. This catches the accidental overwrite that var silently allowed.
const — Block Scoped, No Reassignment
const is let with one additional constraint: the binding cannot be reassigned.
const PI = 3.14159
PI = 3 // TypeError: Assignment to constant variable
const user = { name: 'Alice' } user = {} // TypeError — can't reassign the binding
user.name = 'Bob' // Fine — the binding didn't change, the object's contents did
This is the most common const misconception: const doesn't make values immutable, it makes bindings permanent. The reference must stay the same; what that reference points to can change freely.
const arr = [1, 2, 3]
arr.push(4) // Fine — arr still points to the same array
arr = [1, 2, 3, 4] // TypeError — arr can't point to a different array
If you need deep immutability (where the object contents also can't change), use Object.freeze(). But note that freeze is also shallow — nested objects remain mutable.
The Scoping Difference That Matters Most
Here's a side-by-side that isolates exactly how scoping differs:
// --- var leaks from blocks ---
{
var a = 1
}
console.log(a) // 1 — var escaped the block
// --- let and const stay in blocks --- { let b = 2 const c = 3 } console.log(b) // ReferenceError console.log(c) // ReferenceError
// --- var in a function stays in the function --- function fn() { var d = 4 } fn() console.log(d) // ReferenceError — functions are the one boundary var respects
The Hoisting Difference in Practice
// All three are hoisted — but with different outcomes:
console.log(a) // undefined (var: hoisted + initialized)
console.log(b) // ReferenceError (let: hoisted, in TDZ)
console.log(c) // ReferenceError (const: hoisted, in TDZ)
var a = 1 let b = 2 const c = 3
Interview Output Question
function test() {
console.log(a) // ?
console.log(b) // ?
var a = 'var' let b = 'let'
if (true) { var a = 'var-inner' // same a as above — var ignores the block let b = 'let-inner' // new b, scoped to this block only console.log(a) // ? console.log(b) // ? }
console.log(a) // ? console.log(b) // ? } test()
Output: undefined, ReferenceError (execution stops here)
If we swap the console.log(b) TDZ access to after the let b declaration: undefined, 'var-inner', 'let-inner', 'var-inner', 'let'
The var a inside the if overwrites the function-level a. The let b inside the if is a completely separate variable from the function-level b.
When to Use Each
const — your default choice. Use it for anything that doesn't need reassignment, which is most variables: imported modules, computed values, DOM references, object/array references, function expressions. The constraint communicates intent: this reference won't change.
let — only when reassignment is actually needed. Loop counters, accumulated totals, state that genuinely changes, variables initialized before a conditional assignment.
var — never in new code. Every use case for var is better served by let or const. The only reason it exists is backwards compatibility.
A practical rule: start every variable as const. If you find yourself needing to reassign it, change it to let. Never reach for var.