JavaScript Variables: let, const, and var — The Practical Differences in 2026
JavaScript has three keywords for declaring a variable: var, let, and const. They look similar. They behave very differently. The choice between them affects scoping, hoisting, mutability, and a handful of edge cases that have bitten every JavaScript developer at least once.
This lesson explains exactly what each one does, the rules that drive the differences, and the modern conventions that emerged after ES2015 (which introduced let and const and forced everyone to rethink var).
If you want the punchline up front: in modern JavaScript, use const by default, let when you must reassign, never var. The rest of the article explains why.
A quick declaration tour #
Each keyword binds a name to a value. They all do that. Here is the same idea three ways:
var a = 1;
let b = 2;
const c = 3;
At a glance they look interchangeable. The differences live in five dimensions: scope, hoisting, reassignment, redeclaration, and the global object. We'll work through each.
Scope: block vs. function #
This is the single biggest difference and the reason let/const exist.
varis function-scoped. Avardeclared inside any function is visible everywhere inside that function, regardless of which block it's in.letandconstare block-scoped. They are only visible inside the{ ... }block they were declared in.
function example() {
if (true) {
var fromVar = 'I leak out';
let fromLet = 'I stay inside';
}
console.log(fromVar); // 'I leak out'
console.log(fromLet); // ReferenceError: fromLet is not defined
}
The if block doesn't create scope for var — only functions do. For let and const, every { } block creates a fresh scope.
This matters most in loops. Consider:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Logs: 3, 3, 3
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0);
}
// Logs: 0, 1, 2
Both loops look identical. They produce different results because var i is one shared variable that the three callbacks close over (all see its final value, 3), while let j creates a fresh binding per iteration (each callback closes over its own value).
This single behavior change — let rebinding per loop iteration — is one of the most quietly important improvements ES2015 brought to the language.
Hoisting: undefined vs. the TDZ #
"Hoisting" means the JavaScript engine processes declarations before it runs any code in a scope. All three keywords are hoisted, but the experience of accessing a variable before its declaration differs sharply.
console.log(a); // undefined
var a = 1;
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;
varhoists the declaration and initializes it toundefined. Accessing it before the assignment line returnsundefined. No error.letandconsthoist the declaration too, but leave the binding uninitialized until the declaration line runs. Accessing it earlier throws aReferenceError. The window between the start of the block and the actual declaration line is called the Temporal Dead Zone (TDZ).
The TDZ is a feature, not a bug. It catches reads that would have silently returned undefined under var rules — which often hide real bugs.
We go deeper on hoisting in Lesson 2.3 (Module 2). For now: think of var as "silently undefined before its line" and let/const as "errors before their line."
Reassignment: const can't be rebound #
let x = 1;
x = 2; // fine
const y = 1;
y = 2; // TypeError: Assignment to constant variable.
const prevents reassignment of the binding. The variable name cannot be pointed at a different value after the initial assignment.
This is the most misunderstood part of const. People often read it as "the value is immutable." It is not.
const arr = [1, 2, 3];
arr.push(4); // fine — the array is mutated
console.log(arr); // [1, 2, 3, 4]
arr = []; // TypeError — can't rebind
const obj = { count: 0 };
obj.count = 5; // fine — the object's property changed
obj = {}; // TypeError — can't rebind
const locks the reference, not the contents. If you want a truly immutable value, you reach for Object.freeze, a structural-sharing library like Immer, or a readonly type in TypeScript. We cover that in Module 3 (Property descriptors).
Redeclaration: var lets you do it, let and const don't #
var a = 1;
var a = 2; // fine
let b = 1;
let b = 2; // SyntaxError: Identifier 'b' has already been declared
With var, declaring the same name twice in the same scope is silently accepted. With let and const, it's a syntax error.
The var behavior was originally intended as a feature — you could safely add a var to defensive code without worrying whether the variable already existed. In practice, it hid bugs, especially in long files where someone shadowed an earlier var by accident.
The global object: var leaks, let and const don't #
Declare any of the three at the top of a script (not inside a function), and watch what happens to window / globalThis:
// At the top level of a script in the browser
var a = 1;
let b = 2;
const c = 3;
console.log(window.a); // 1 — var attaches to the global object
console.log(window.b); // undefined — let does not
console.log(window.c); // undefined — const does not
Top-level var declarations become properties of the global object. Top-level let and const declarations live in a separate "script scope" that does not pollute globals.
This matters for two reasons:
- Accidental globals. A typo or a stray top-level
varcan collide with a library or browser API. Modern code avoids this entirely by usinglet/const. - Module-level state. When you use ES modules (which we cover in Module 6), the
var/let/constdistinction at the top level matters less because modules have their own scope — but understanding why top-levelvarhistorically leaked helps you read older code.
The five-dimension summary #
| Dimension | var |
let |
const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes, initialized to undefined |
Yes, but in TDZ until declaration | Yes, but in TDZ until declaration |
| Reassignment | Allowed | Allowed | Not allowed |
| Redeclaration in same scope | Allowed | Error | Error |
| Attaches to global object (top level) | Yes | No | No |
When you internalize this table, the decision tree becomes obvious.
The modern convention #
The rule that practically every team in 2026 follows:
- Default to
const. Every variable isconstunless you have a specific reason it can't be. - Reach for
letwhen you need to reassign. Loop counters, accumulators, values that change inside anif/elsebranch. - Avoid
varentirely. There is no problemvarsolves thatletorconstdoesn't solve better.
This isn't aesthetic preference. It encodes intent:
- Seeing
consttells the reader "this name will always point at this value within this scope." That's a useful constraint that the runtime enforces. - Seeing
lettells the reader "this name will be reassigned at some point — watch for that." Useful warning. - Seeing
varin modern code tells the reader nothing extra and forces them to mentally check whether the author meant function-scoping on purpose. Cognitive cost with no payoff.
Linters (ESLint with prefer-const, no-var) enforce this convention by default in most modern starters.
When var still shows up legitimately #
Three cases:
- Legacy code you can't or shouldn't rewrite. Read it, understand it, but write new code differently.
- Code that targets very old browsers (pre-ES2015). Vanishingly rare in 2026 — even IE11 is dead in most enterprise stacks. If you're still in that world, transpilers like Babel let you write
let/constand emitvar. - A handful of niche tricks involving function-scoped hoisting that some library authors still find useful. Worth recognizing, never worth imitating.
A worked example: refactoring var #
Here's a real-shaped code review:
// Before — three different bugs hiding in var
function processUsers(users) {
for (var i = 0; i < users.length; i++) {
var u = users[i];
if (u.active) {
var name = u.name;
setTimeout(() => console.log(name), 100);
}
}
console.log(i); // unexpected: still accessible
}
Three problems:
- All
setTimeoutcallbacks log the lastnameassigned (closure-over-shared-var bug). ileaks out of the loop and is accessible after — usually a bug, occasionally relied on by mistake.var uandvar namedeclared inside theiflook block-scoped but aren't.
The modern version:
function processUsers(users) {
for (let i = 0; i < users.length; i++) {
const u = users[i];
if (u.active) {
const name = u.name;
setTimeout(() => console.log(name), 100);
}
}
// i is not accessible here — correct
}
Each callback closes over its own name because const name is fresh per iteration. i is scoped to the for header. u is scoped to the loop body. The code's behavior now matches what a reader would assume from reading it.
This is the everyday payoff of let/const: code that does what it looks like it does.
What this means for the rest of the tutorial #
From here on, every example will use const and let exclusively. When you see var in an example later, it will be because the lesson is specifically about historical behavior. The mental model going forward:
- Block scope is the default scoping rule.
- Variables are immutable bindings unless you opt into mutability with
let. - Reading a variable before its declaration throws.
Keep those three statements in mind and most variable-related surprises stop being surprises.
What's next #
Lesson 1.3 covers data types — the eight values JavaScript actually distinguishes between, the difference between primitives and objects, the surprising rules around typeof, and why typeof null returns 'object' (a 30-year-old bug nobody can fix).
Try it yourself #
The TDZ is the easiest way to feel the difference between var and let in two seconds. Paste this into any Node REPL or browser console:
{ console.log(typeof x); var x = 1; }
{ console.log(typeof y); let y = 1; }js_sandboxThe first line logs 'undefined' — var x is hoisted and initialized to undefined before the declaration line runs, so typeof returns the type of undefined.The second line throws
ReferenceError: Cannot access 'y' before initialization — let y is hoisted but lives in the Temporal Dead Zone until its declaration runs. Even typeof can’t read it.That's the TDZ in one experiment. var silently gives you undefined. let and const refuse to lie. Pick the one that refuses to lie.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


