var vs. let vs. const in JavaScript Explained

Table of Contents

Last updated: April 13, 2025

Introduction: Declaring Variables in JavaScript

Variables are fundamental building blocks in any programming language, acting as named containers for storing data values. In JavaScript, how you declare a variable significantly impacts its behavior, especially concerning its scope (where it's accessible) and whether its value can be changed. Before ES6 (ECMAScript 2015), `var` was the only way to declare variables. ES6 introduced `let` and `const`, providing more control and helping developers write cleaner, less error-prone code.

Understanding `var` (The Old Way)

The `var` keyword was the original way to declare variables in JavaScript. It has some quirks that can lead to unexpected behavior in modern development.

Scope

  • Scope: `var` variables are either globally scoped (if declared outside any function) or function scoped (if declared inside a function). They are not block-scoped (e.g., inside `if` statements or `for` loops).
  • Hoisting: `var` declarations are "hoisted" to the top of their scope (global or function) during compilation. This means the variable exists throughout the scope, but it's initialized with `undefined` until its declaration line is reached.
  • Re-declaration: You can re-declare the same variable using `var` within the same scope without errors.
  • Re-assignment: You can update the value of a `var` variable.
  • Global Object Property: When declared in the global scope (outside functions), `var` creates a property on the global object (`window` in browsers, `global` in Node.js). However, this does not apply to variables declared in modules (files using `import`/`export`).
  • Global Object Property Exception: Note that `var` declarations in ES modules (files using `import`/`export`) do not create properties on the global object, even at the top level.

Example (`var` Scope):

function varScopeTest() {
  if (true) {
    var message = "Hello from inside if"; 
  }
  console.log(message); // Output: "Hello from inside if" - message leaks outside the block
}
varScopeTest();
// console.log(message); // ReferenceError: message is not defined (outside the function scope)

Hoisting

Example (`var` Hoisting):

console.log(myVar); // Output: undefined (hoisted but not yet assigned)
var myVar = 10;
console.log(myVar); // Output: 10

Re-declaration

Example (`var` Re-declaration):

var x = 5;
console.log(x); // Output: 5
var x = 20; // No error
console.log(x); // Output: 20

Due to these behaviors, especially the lack of block scope, `var` is generally avoided in modern JavaScript development in favor of `let` and `const`.

Introducing `let` (The Modern Mutable Choice)

Introduced in ES6, `let` provides a more predictable way to declare variables whose values might need to change later.

Block Scope

  • Scope: `let` variables are block-scoped. They only exist within the block (`{...}`) in which they are declared (e.g., inside an `if`, `for` loop, or just a standalone block).
  • Hoisting & Temporal Dead Zone (TDZ): While `let` and `const` declarations are hoisted, their TDZ prevents any access before declaration. Attempting to access the variable before its declaration will result in a `ReferenceError`. This differs from `var` which returns `undefined` when accessed before declaration.
  • Re-declaration: You cannot re-declare the same variable using `let` within the same scope.
  • Re-assignment: You can update (reassign) the value of a `let` variable.
  • Global Object Property: `let` does not create a property on the global object when declared in the global scope.

Example (`let` Scope):

function letScopeTest() {
  if (true) {
    let blockMessage = "Hello from inside if"; 
    console.log(blockMessage); // Output: "Hello from inside if"
  }
  // console.log(blockMessage); // ReferenceError: blockMessage is not defined (outside the block scope)
}
letScopeTest();

Temporal Dead Zone (TDZ)

Example (`let` TDZ):

// console.log(myLet); // ReferenceError: Cannot access 'myLet' before initialization (TDZ)
let myLet = 30;
console.log(myLet); // Output: 30

Re-declaration Rules

Example (`let` Re-declaration):

let y = 15;
console.log(y); // Output: 15
// let y = 25; // SyntaxError: Identifier 'y' has already been declared

Use `let` when you need a variable whose value will change over time, like loop counters or temporary assignments.

Understanding `const` (For Constant References)

`const` (also introduced in ES6) is used to declare variables whose values are intended to remain constant. However, there's an important nuance regarding objects and arrays.

Working with Objects

  • Scope: `const` variables are block-scoped, just like `let`.
  • Hoisting & Temporal Dead Zone (TDZ): `const` declarations are hoisted but not initialized, creating a TDZ, just like `let`. Accessing before declaration results in a `ReferenceError`.
  • Re-declaration: You cannot re-declare the same variable using `const` within the same scope, just like `let`.
  • Re-assignment: You cannot reassign a `const` variable after it's declared. This is its primary characteristic.
  • Initialization: `const` variables must be initialized with a value when they are declared.
  • Global Object Property: `const` does not create a property on the global object.
  • Important Nuance (Objects/Arrays): `const` makes the binding (the variable name) constant, not necessarily the value it holds. If a `const` variable holds an object or an array, you cannot reassign the variable to a *new* object or array, but you *can* modify the properties of the object or the elements of the array.
  • Object Immutability: If you need true immutability for an object declared with `const`, you can use `Object.freeze()`:
    const frozenPerson = Object.freeze({ name: "Alice" });
    frozenPerson.name = "Bob"; // This will fail silently in non-strict mode
    // or throw TypeError in strict mode
  • Using `const` with Destructuring: When using destructuring assignment with `const`, each destructured variable becomes a constant:

    Destructuring

    const { name, age } = person;
    name = "Bob"; // TypeError: Assignment to constant variable
    // However, if person is an object:
    person.name = "Bob"; // This works as we're modifying the object, not reassigning the const
  • Object Properties: While `Object.freeze()` can prevent modifications to an object's direct properties, it only creates a shallow freeze. Nested objects can still be modified unless they are also frozen:

    Object.freeze()

    const person = Object.freeze({
      name: "Alice",
      address: { city: "London" }
    });
    person.name = "Bob"; // Fails (frozen)
    person.address.city = "Paris"; // Works (nested object not frozen)
    

Example (`const` Re-assignment):

const PI = 3.14159;
console.log(PI); // Output: 3.14159
// PI = 3.14; // TypeError: Assignment to constant variable.

Example (`const` Initialization):

// const GREETING; // SyntaxError: Missing initializer in const declaration
const GREETING = "Hello"; 

Example (`const` with Objects):

const person = { name: "Alice", age: 30 };
console.log(person); // Output: { name: 'Alice', age: 30 }

// This is allowed: Modifying the object's property
person.age = 31; 
console.log(person); // Output: { name: 'Alice', age: 31 }

// This is NOT allowed: Reassigning the constant variable
// person = { name: "Bob", age: 40 }; // TypeError: Assignment to constant variable.

Use `const` by default for all declarations. This signals that the variable's assignment shouldn't change, which makes code easier to reason about and prevents accidental reassignments.

Additional Scoping Considerations

When working with ES modules, there's an additional scope to consider:

  • Module Scope: Variables declared at the top level of a module (file) are scoped to that module, regardless of whether you use `var`, `let`, or `const`.
  • Import/Export: You can only export variables that are declared at the module scope level.
// moduleA.js
export const config = { api: 'example.com' };
var helper = 'utility'; // Only accessible within this module
export { helper }; // Can still export it explicitly

// moduleB.js
import { config, helper } from './moduleA.js';
console.log(config.api); // Works
console.log(window.helper); // undefined - not added to global object

Key Differences Summarized

Feature var let const
Scope Function or Global Block Block
Hoisting Hoisted & Initialized (undefined) Hoisted & Not Initialized (TDZ) Hoisted & Not Initialized (TDZ)
Re-declaration (same scope) Allowed Not Allowed Not Allowed
Re-assignment Allowed Allowed Not Allowed
Must Be Initialized No No Yes
Global Object Property Yes (in global scope) No No

When to Use Which?

The modern best practice in JavaScript (ES6+) is straightforward:

  1. Use `const` by default: Start by declaring all your variables with `const`. This makes your code more predictable as it guarantees the variable binding will not be reassigned.
  2. Use `let` only if you need to reassign the variable: If you know a variable's value needs to change during its lifecycle (e.g., a loop counter, a state variable that gets updated), then use `let`.
  3. Avoid `var`: There is generally no reason to use `var` in modern JavaScript codebases. `let` and `const` offer better scoping rules and help prevent common bugs associated with `var`.
  4. Consider block scope for temporary variables: Use blocks (even without control statements) to limit the scope of temporary variables:
    {
      const temp = heavyComputation();
      // temp only exists in this block
      doSomethingWith(temp);
    }

By following these guidelines, you can write cleaner, more maintainable, and less error-prone JavaScript code.