Choosing between let, const and var is the first real style decision in JavaScript, and most of the confusion around it comes from three myths: that const makes a value immutable, that var and let differ only in timing, and that var is somehow the flexible one. Here is what each keyword actually does to your program.
What is the real difference between the three keywords?
A variable in JavaScript is a binding — a name attached to a value. The keyword you choose decides whether that name can be pointed at a different value later, not what the value itself is.
| Keyword | Can be reassigned? | Scope | Still worth using? |
|---|---|---|---|
const | No | Block | Yes — your default |
let | Yes | Block | Yes — when the value genuinely changes |
var | Yes | Function | Almost never |
That is the whole distinction. Everything else — hoisting, the temporal dead zone, the loop bug — follows from how the binding behaves.
Does const make a value immutable?
No, and this is the myth that causes the most confusion. const freezes the binding, not the value. If the value is an object or an array, its contents are still yours to change.
const settings = { theme: "light", notifications: true };
settings.theme = "dark"; // fine: the object was modified, not the binding
console.log("after mutation:", settings.theme);
try {
settings = { theme: "solar" }; // TypeError: reassigning a const binding
} catch (error) {
console.log("caught:", error.constructor.name, "-", error.message);
}
Run that and it prints after mutation: dark followed by caught: TypeError - Assignment to constant variable. If you want a value that genuinely cannot change, you need a frozen object, which is a different tool with different trade-offs.
Why was var retired?
Three reasons, and each one has bitten somebody.
First, var is function-scoped, not block-scoped. A var declared inside an if or a for block is visible for the whole enclosing function, which means a name meant for one branch leaks into every other branch.
function withVar() {
if (true) {
var leaked = "declared with var";
}
return leaked; // works: var is function-scoped
}
function withLet() {
if (true) {
let scoped = "declared with let";
}
return scoped; // ReferenceError: scoped is not defined
}
console.log("var:", withVar());
try {
withLet();
} catch (error) {
console.log("let:", error.constructor.name, "-", error.message);
}
Second, var in a loop shares one binding across all iterations, so every closure created in the loop ends up seeing the final value. let creates a fresh binding per iteration.
// var: every closure shares one variable.
const varCallbacks = [];
for (var i = 0; i < 3; i += 1) {
varCallbacks.push(() => i);
}
console.log("var results:", varCallbacks.map((fn) => fn()));
// let: each iteration gets its own binding.
const letCallbacks = [];
for (let j = 0; j < 3; j += 1) {
letCallbacks.push(() => j);
}
console.log("let results:", letCallbacks.map((fn) => fn()));
The output is the clue: var results: [ 3, 3, 3 ] but let results: [ 0, 1, 2 ]. The var version printed 3 three times because all three arrows point at the same i, which finished the loop at 3. The let version printed 0, 1 and 2 because each iteration captured its own copy.
Third, var at the top level of a script becomes a property of the global object, so it can collide with other scripts on the same page. let and const at the top level do not.
What is the temporal dead zone?
Both var and let are hoisted — the engine knows about the binding before your code reaches it. What differs is the state of the binding at that moment. A hoisted var exists but holds undefined. A hoisted let exists but is uninitialised, and touching it in that window is an error. That window is called the temporal dead zone.
console.log("var value:", hoistedVar); // undefined, not an error
var hoistedVar = "assigned later";
try {
console.log("let value:", hoistedLet); // ReferenceError
} catch (error) {
console.log("let before init:", error.constructor.name, "-", error.message);
}
let hoistedLet = "assigned later";
The first line prints var value: undefined. The second throws a ReferenceError with the message Cannot access 'hoistedLet' before initialization. This is why calling a function before its const declaration fails, while calling a function declaration before its definition works — the two use the keywords differently, and function declarations are covered in detail later in this module.
Which keyword should I actually use?
A simple, defensible rule: declare with const by default, switch to let only when the binding really has to change, and do not write new var at all.
const MAX_RETRIES = 3;
let attempts = 0;
while (attempts < MAX_RETRIES) {
attempts += 1;
console.log(`attempt ${attempts} of ${MAX_RETRIES}`);
}
Three attempts, three log lines, and the reader can tell from the declaration alone which values are fixed and which move. That single glance is worth more than any amount of extra documentation.
There is one nuance worth keeping in mind: const does not make a value immutable, it makes the name stable. A const holding a large object is still mutable underneath, so const is a statement of intent about the binding, not a guarantee about the data. If you want the data itself frozen, say so explicitly with Object.freeze and accept that it is a shallow freeze.
How do I convert old var code safely?
Most code you will read online still uses var, because it was the only option for its first fifteen years. Converting it is usually mechanical: change the keyword, run the tests. There is one exception you have to check by hand — any name that was declared inside a block and read outside it, because that is exactly the case let will break.
// Before: the name exists outside the block and reads as undefined.
function surcharge(orders) {
if (orders.length > 0) {
var hasOrders = true;
}
return hasOrders ? 10 : 0;
}
console.log("with orders:", surcharge([1])); // 10
console.log("no orders:", surcharge([])); // 0
After converting that var to let, the second call throws a ReferenceError — because hasOrders no longer exists outside the block. The fix is to move the read inside the block where the name is still in scope:
// After: the binding stays inside the block, so read it there.
function surcharge(orders) {
if (orders.length > 0) {
let hasOrders = true;
return hasOrders ? 10 : 0;
}
return 0;
}
console.log("with orders:", surcharge([1])); // 10
console.log("no orders:", surcharge([])); // 0
The two versions print the same two lines, but the second one no longer depends on a variable that happens to be undefined. That is the pattern to look for when you convert: a name read outside the block that declared it was always a bug waiting for the right input.
The three rules to remember
conststops the name from being repointed. It does not freeze the value.letandconstare block-scoped;varis function-scoped and leaks out of blocks.letandconstlive in a temporal dead zone until their declaration runs;varstarts life asundefined.- Default to
const, useletfor genuine change, avoidvarexcept in code you are maintaining rather than writing.
Try it yourself: take any script you have written so far and change every var to let. Most will keep working, which proves the two are often interchangeable in simple code. Then find a var declared inside an if block that is read after the block, and watch the let version throw a ReferenceError where the var version silently worked. That crash is the bug var was hiding.