Blog

JavaScript var let const

Key Takeaways

  • Default to const; use let when you need to reassign the binding.
  • Inside functions, var ignores ordinary block boundaries, while let and const are block-scoped.
  • const prevents reassignment, not mutation of an array or object.
  • Before initialization, let and const are inaccessible; a newly created var binding starts as undefined.

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

Behaviorvarletconst
Scope inside ordinary function blocksEnclosing functionEnclosing blockEnclosing block
Can be reassignedYesYesNo
Can repeat the same declaration in the same scopeYesNoNo
Read before its declaration executesInitially undefinedReferenceErrorReferenceError
Initializer in an ordinary declarationOptionalOptionalRequired

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 unchanged

The 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 block

Here, 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 initializer

numbers.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

  1. Start with const when the value is available at declaration.
  2. Use let when the binding needs reassignment or initialization later in the same scope.
  3. Keep var only 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

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me