Previous Article
Modify the WooCommerce Product Query and Set Visibility on Import
5 December 2018
JavaScript has three ways to declare a variable, and the differences between them cause a surprising share of real bugs. This is the complete comparison — scope, hoisting, reassignment, redeclaration, and the traps that catch people who think they already know the answer.
const — block scoped, cannot be reassigned. Use this by default.let — block scoped, can be reassigned. Use when the value genuinely changes.var — function scoped, legacy. Avoid in new code.Everything below is why.
var is scoped to the enclosing function. let and const are scoped to the enclosing block — any { }, including those of if, for and while.
function scopeDemo() {
if (true) {
var a = 'var';
let b = 'let';
const c = 'const';
}
console.log(a); // "var" — leaked out of the if block
console.log(b); // ReferenceError
console.log(c); // ReferenceError
}
That leak is the single biggest practical argument against var. Variables escape the block you wrote them in and remain visible for the rest of the function, which makes accidental collisions easy.
You will often read that "let and const are not hoisted". That is a convenient shorthand, but it is not true, and believing it will confuse you the first time you hit a real TDZ error.
All three are hoisted — the declaration is registered in its scope before any code runs. What differs is what happens if you touch the variable before its line.
console.log(a); // undefined console.log(b); // ReferenceError: Cannot access 'b' before initialization var a = 1; let b = 2;
var is initialised to undefined immediately, so an early read silently returns undefined. let and const stay uninitialised until execution reaches the declaration; that window is the temporal dead zone (TDZ), and reading inside it throws.
The difference in error message proves the hoisting. An undeclared variable gives you ReferenceError: b is not defined. A TDZ variable gives you Cannot access 'b' before initialization — the engine knows b exists in this scope, which it could only know if the declaration had been hoisted.
The throw is the better behaviour. A ReferenceError pointing at the exact line beats an undefined that quietly flows through your code and blows up somewhere unrelated.
var a = 1; a = 2; // fine let b = 1; b = 2; // fine const c = 1; c = 2; // TypeError: Assignment to constant variable
const also requires an initialiser — const x; is a syntax error, whereas let x; is fine.
This is the most common misunderstanding. const prevents rebinding the variable. It says nothing about the value's contents:
const user = { name: 'Ada' };
user.name = 'Grace'; // allowed — mutating the object
user.role = 'engineer'; // allowed — adding a property
user = { name: 'Alan' }; // TypeError — rebinding the variable
const list = [1, 2, 3];
list.push(4); // allowed — list is now [1, 2, 3, 4]
list = []; // TypeError
If you want the value itself frozen, that is a separate operation:
const config = Object.freeze({ retries: 3, nested: { timeout: 500 } });
config.retries = 5; // silently ignored (throws in strict mode)
config.nested.timeout = 900; // still works — freeze is shallow
Note the last line. Object.freeze() is shallow; nested objects need freezing recursively if you want deep immutability.
var a = 1; var a = 2; // fine — silently overwrites let b = 1; let b = 2; // SyntaxError: Identifier 'b' has already been declared
Silent redeclaration is genuinely dangerous in a large file: you can clobber a variable hundreds of lines away and never know. let and const turn that into an error before the code ever runs.
A top-level var in a classic script becomes a property of the global object. let and const do not.
var x = 1; let y = 2; const z = 3; console.log(window.x); // 1 console.log(window.y); // undefined console.log(window.z); // undefined
This matters because var at the top level can overwrite built-in globals. Declaring var name = 'Ada' in a script clobbers window.name, which is a real browser property, and the resulting bugs are baffling.
Inside an ES module none of this applies — module top-level declarations are module-scoped regardless of keyword.
The classic interview question, and a real bug people still ship:
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 one i shared by every callback. The loop finishes before any timeout fires, so all three read the final value, 3.
With let, the specification creates a fresh binding per iteration and copies the current value into it, so each closure captures its own i. This is special-cased behaviour for let in for loops — not something you could derive from block scoping alone.
Before let existed, the workaround was an IIFE to create a new scope per iteration:
for (var i = 0; i < 3; i++) {
(function (captured) {
setTimeout(() => console.log(captured), 0);
})(i);
}
// 0, 1, 2
for (const i = 0; i < 3; i++) { } // TypeError on the first i++
for (const item of items) { } // fine — new binding each iteration
for (const key in object) { } // also fine
A C-style for loop reassigns its counter, so const fails. for…of and for…in create a new binding per iteration and never reassign, so const is correct there and is what you should use.
| Behaviour | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes, as undefined | Yes, into TDZ | Yes, into TDZ |
| Read before declaration | undefined | ReferenceError | ReferenceError |
| Reassignable | Yes | Yes | No |
| Redeclarable in same scope | Yes | No | No |
| Initialiser required | No | No | Yes |
| Creates a global object property | Yes | No | No |
| New binding per loop iteration | No | Yes | Yes (for…of / for…in) |
const first. Most variables are never reassigned. Making that explicit means a reader can stop tracking the value the moment they see the declaration.let only when you actually reassign — a loop counter, an accumulator, a value that changes across branches.var in new code. There is no situation where it is the better choice. Its only remaining role is understanding legacy files.The payoff is not stylistic. const by default makes reassignment stand out as a deliberate signal, block scoping keeps variables where you declared them, and the TDZ converts a class of silent undefined bugs into loud errors at the exact line responsible.
Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.