Variables: var, let & const
Variables are named bindings that hold values your program works with. JavaScript gives you three ways to declare them — const, let, and var — but they behave very differently around scope, reassignment, and hoisting. Choosing the right one is one of the highest-leverage habits you can build: it makes code easier to reason about and quietly prevents a whole category of bugs.
Declaring variables
A declaration introduces a name; an assignment gives it a value. With const you must do both at once, because a const binding can never be reassigned. With let you can declare first and assign later, and reassign as often as you like.
const pi = 3.14159; // declared and assigned together
let count = 0; // can change later
count = count + 1;
let label; // declared without a value -> undefined
label = "ready";
Tip:
constdoes not make a value immutable — it makes the binding immutable. You cannot pointconst userat a new object, but you can still mutate the object it points to (user.name = "Ada"). UseObject.freeze()if you need the contents to be read-only too.
const user = { name: "Grace" };
user.name = "Ada"; // OK — mutating the object
// user = {}; // TypeError — reassigning the binding
Scope: block vs function
let and const are block-scoped: they exist only inside the nearest pair of { } — a function body, an if, a for loop, or even a bare block. var is function-scoped: it ignores blocks and leaks out to the enclosing function (or the global scope).
function scopeDemo() {
if (true) {
let blockScoped = "let";
var functionScoped = "var";
}
console.log(functionScoped); // accessible
console.log(blockScoped); // ReferenceError
}
This difference is most painful in loops. With var, every iteration shares one binding, so closures capture the final value. With let, each iteration gets a fresh binding.
const fns = [];
for (let i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns.map((fn) => fn()));
Output:
[ 0, 1, 2 ]
Swap let for var and every function logs 3, because they all close over the same i.
Reassignment and redeclaration
let can be reassigned but not redeclared in the same scope. const allows neither. var allows both, which is exactly why it hides bugs — you can accidentally clobber an existing variable without any error.
let total = 10;
total = 20; // OK
// let total = 30; // SyntaxError: already declared
var name = "first";
var name = "second"; // no error, silently overwrites
Hoisting and the temporal dead zone
All three declarations are hoisted — the engine knows about them before the line that declares them runs. The difference is what happens if you access them early. var declarations are hoisted and initialized to undefined, so reading one before its assignment gives undefined. let and const are hoisted but not initialized; the span between the top of the block and the declaration is the temporal dead zone (TDZ), and reading the variable there throws.
console.log(early); // undefined (var is hoisted + initialized)
var early = 1;
console.log(later); // ReferenceError — TDZ
let later = 2;
Warning: The TDZ is a feature, not a bug. It turns “I used this before I defined it” from a silent
undefinedinto a loud error you can fix immediately.
Comparison at a glance
| Feature | const | let | var |
|---|---|---|---|
| Scope | Block | Block | Function |
| Reassignable | No | Yes | Yes |
| Redeclarable (same scope) | No | No | Yes |
| Hoisted | Yes (TDZ) | Yes (TDZ) | Yes (as undefined) |
| Must initialize at declaration | Yes | No | No |
| Creates global property (top level) | No | No | Yes |
The modern rule
The community has converged on a simple decision procedure: reach for const by default. If you discover the binding genuinely needs to change — a loop counter, an accumulator, a value built up conditionally — switch that one to let. Avoid var in new code entirely; its function scoping and silent redeclaration are legacy behavior kept only for backward compatibility.
const apiUrl = "https://api.example.com";
let retries = 0;
while (retries < 3) {
retries += 1;
}
const message = `Tried ${retries} times`;
console.log(message);
Output:
Tried 3 times
This works identically in modern browsers and in Node.js — let and const have been standard since ES2015 and are safe to use everywhere today.
Best Practices
- Default to
const; only downgrade toletwhen you actually reassign the binding. - Never use
varin new code — block scoping and TDZ catch more mistakes earlier. - Declare variables as close as possible to where they are first used, not at the top of the function.
- Remember
constfreezes the binding, not the value — useObject.freeze()for read-only data. - Give variables descriptive, intention-revealing names instead of relying on comments.
- Prefer a fresh
let/constper loop iteration over a shared mutable counter when closures are involved.