Key Takeaways
- Default to
const; useletwhen you need to reassign the binding. - Inside functions,
varignores ordinary block boundaries, whileletandconstare block-scoped. constprevents reassignment, not mutation of an array or object.- Before initialization,
letandconstare inaccessible; a newly createdvarbinding starts asundefined.
Choose JavaScript variable declarations by scope and reassignment needs: prefer const, use let when reassignment is necessary, and retain var mainly for legacy code or intentional function-scoped behavior. The important differences are where a name is accessible, when you can access it, and whether you can assign it another value.
A binding is the connection between a variable name and its value. Reassigning a binding changes what the name holds; mutating an object changes the object without necessarily reassigning the name.
var vs let vs const at a Glance
| Behavior | var | let | const |
|---|---|---|---|
| Scope inside ordinary function blocks | Enclosing function | Enclosing block | Enclosing block |
| Can be reassigned | Yes | Yes | No |
| Can repeat the same declaration in the same scope | Yes | No | No |
| Read before its declaration executes | Initially undefined | ReferenceError | ReferenceError |
| Initializer in an ordinary declaration | Optional | Optional | Required |
The scope distinction explains why moving a declaration into an if block affects let and const, but not var. The ECMAScript declaration rules define these differences; the following examples show their practical consequences.
When to Use var
Within a function, var is function-scoped rather than scoped to ordinary blocks such as an if body. This example demonstrates both function-local shadowing and visibility outside a block:
var x = 1;
function exampleFunction() {
if (true) {
var x = 2;
}
console.log(x); // 2: accessible outside the if block
}
exampleFunction();
console.log(x); // 1: the outer binding is unchangedThe inner x belongs to exampleFunction, not its if block. It is separate from the outer x; declaring it does not reassign the outer binding. See JavaScript function scope examples for more background.
Keep var when maintaining code that deliberately relies on these semantics. For new code, declaring a let outside a block usually expresses a shared, reassignable variable more clearly than relying on var inside it.
When to Use let
Use let for a binding that needs reassignment. Its block scope limits where the name is accessible; it does not prevent reassignment within that scope.
let total = 0;
for (let i = 0; i < 5; i++) {
const j = i * 2;
total += j;
console.log(j); // 0, then 2, 4, 6, 8
}
console.log(total); // 20
// console.log(i); // ReferenceError: outside the loop's scope
// console.log(j); // ReferenceError: outside the body's blockHere, total accumulates a result and i changes as the loop advances, so both use let. The body creates a new j on each iteration without reassigning it, so const fits.
When to Use const
Use const when you can initialize a binding immediately and won't reassign it. An ordinary const declaration requires an initializer, and assigning another value to an initialized const binding throws a TypeError.
const numbers = [1, 2, 3];
numbers.push(4);
console.log(numbers); // [1, 2, 3, 4]
try {
numbers = [5, 6];
} catch (error) {
console.log(error.name); // TypeError
}
// const unfinished; // SyntaxError: missing initializernumbers.push(4) mutates the existing array. By contrast, numbers = [5, 6] attempts to reassign the binding. Object properties work the same way: const user = { active: false }; permits user.active = true;, but not reassignment of user.
Primitive values, such as numbers and strings, are already immutable regardless of the declaration keyword. const controls reassignment of the binding, not whether its value is intrinsically mutable.
For property access and mutation examples, see working with JavaScript objects.
Hoisting and the Temporal Dead Zone
Hoisting describes declaration-related behavior that takes effect before execution reaches the declaration. It does not mean JavaScript physically moves your source code.
A newly created var binding is initialized to undefined; its initializer executes later. let and const bindings also exist from the start of their scope, but remain uninitialized until their declarations initialize them. That inaccessible interval is the temporal dead zone, or TDZ.
function showInitialization() {
console.log(legacy); // undefined
var legacy = 1;
try {
console.log(later);
} catch (error) {
console.log(error.name); // ReferenceError
}
let later = 1;
console.log(later); // 1
}
showInitialization();Replacing let later = 1; with const later = 1; produces the same TDZ behavior. A declaration such as let pending; initializes the binding to undefined when execution reaches it—not at scope entry. Even typeof throws a ReferenceError when applied to a binding in its TDZ.
The useful rule isn't “let and const aren't hoisted.” It's “don't access them before initialization.”
Redeclaration and Shadowing Are Different
var permits repeated declarations of the same binding. A repeated declaration without an initializer does not reset its value. let and const reject same-scope redeclaration, including conflicts with var; these are early SyntaxErrors, detected before the affected script or module executes.
var count = 1;
var count;
console.log(count); // 1
let status = "outer";
{
const status = "inner";
console.log(status); // inner
}
console.log(status); // outer
// Uncomment any one of these to cause a SyntaxError:
// let status = "again";
// const status = "again";
// var status;The nested status is legal shadowing: a separate binding hides the outer name inside a nested scope. However, reading that name before the inner declaration initializes it hits the inner binding's TDZ rather than returning the outer value.
Why let Behaves Differently in Loop Callbacks
A closure is a function that retains access to bindings from its surrounding scope. Callbacks created in a var loop share one loop binding; declaring the counter with let in the loop header gives iterations separate bindings.
function captureWithVar() {
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => i);
}
return callbacks.map((callback) => callback());
}
function captureWithLet() {
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(() => i);
}
return callbacks.map((callback) => callback());
}
console.log(captureWithVar()); // [3, 3, 3]
console.log(captureWithLet()); // [0, 1, 2]The callbacks execute after their loop finishes. With var, they all read the same final counter value. With let, each reads its own iteration's binding. Declaring let i outside the loop would still give callbacks one shared binding.
For value iteration, const works when you don't reassign the iteration variable:
for (const number of [1, 2, 3]) {
console.log(number);
}A for...of loop initializes a fresh binding each iteration, so it does not need an explicit initializer after number. See JavaScript loop examples for the different loop forms.
Replacing var Safely in Existing Code
Don't treat migration as search-and-replace. Check each binding for:
- Reads outside the block containing its declaration.
- Reads before initialization.
- Repeated declarations that would become syntax errors.
- Loop callbacks whose captured values would change.
- Code accessing a top-level declaration through the global object.
In a classic browser script, a top-level var creates a property on the global object; top-level let and const do not. In an ECMAScript module, top-level declarations are module-scoped instead. Changing the keyword can therefore break legacy code that reads a variable through window or globalThis.
Refactor one scope at a time, update intentional cross-block access explicitly, and test observable behavior—especially callbacks and script integration.
Conclusion: Choose by Reassignment and Scope
- Start with
constwhen the value is available at declaration. - Use
letwhen the binding needs reassignment or initialization later in the same scope. - Keep
varonly when preserving or deliberately choosing its semantics.
To enforce that convention, ESLint's no-var rule disallows var, while prefer-const flags eligible let declarations that are never reassigned. Introduce them alongside a reviewed migration; our introduction to JavaScript linters explains their role in code review.
FAQ
Should I use const or let by default in JavaScript?
Default to const when you can initialize the binding immediately and won't reassign it. Use let when reassignment or later initialization is required; both provide block scope instead of var's function scope inside ordinary function blocks.
Can I replace all var declarations with let?
Not safely without reviewing their behavior. Conversion can change block visibility, access before initialization, redeclaration rules, loop closures, and global-object properties in classic scripts.
Does const make arrays and objects immutable?
No. const prevents reassignment of the binding, but it does not prevent changing an object's properties or adding elements to an array.
What is the temporal dead zone for let and const?
The temporal dead zone is the interval from scope entry until a let or const binding is initialized. Accessing the binding during that interval throws a ReferenceError, including when using typeof.
Can var, let, or const be redeclared in the same scope?
var allows repeated declarations of the same binding. let and const reject same-scope redeclarations, including conflicts with var, with an early SyntaxError; a separate declaration in a nested scope can legally shadow an outer binding.
Further Reading
- MDN: var — function scope, hoisting, redeclaration, and script globals.
- MDN: let — block scope, shadowing, and the temporal dead zone.
- MDN: const — initialization requirements and immutable bindings.
- MDN: for — per-iteration bindings and closure examples.
- ECMAScript: statements and declarations — normative declaration and loop semantics.
- MDN: Object.freeze() — object freezing and its shallow behavior.
- ESLint: no-var — enforcing let and const instead of var.
- ESLint: prefer-const — identifying bindings that don't need reassignment.
