# Var vs Let vs Const: Scope and Hoisting Differences Explained

> Understand var let const differences Learn about scope hoisting and the Temporal Dead Zone in JavaScript Master these core JS concepts to write cleaner more robust code

- Repository: [Leonardo Maldonado/33-js-concepts](https://github.com/leonardomso/33-js-concepts)
- Tags: deep-dive
- Published: 2026-03-03

---

**`var` is function-scoped and hoisted with an initial value of `undefined`, while `let` and `const` are block-scoped and enter a Temporal Dead Zone (TDZ) that throws a `ReferenceError` if accessed before declaration.**

JavaScript provides three ways to declare variables, but they behave fundamentally differently regarding where they exist and when they become available. According to the [leonardomso/33-js-concepts](https://github.com/leonardomso/33-js-concepts) repository, understanding these distinctions is critical for avoiding scope-related bugs and hoisting confusion.

## Scope: Function vs Block

The most visible difference between these keywords is the **scope** in which variables live.

### Var Function Scope

Variables declared with `var` are **function-scoped**. If declared outside a function, they become global. Crucially, a `var` declared inside a block (such as an `if` statement or `for` loop) escapes that block and remains visible throughout the entire function.

As demonstrated in [`tests/fundamentals/scope-and-closures/scope-and-closures.test.js`](https://github.com/leonardomso/33-js-concepts/blob/main/tests/fundamentals/scope-and-closures/scope-and-closures.test.js):

```javascript
function demoVar() {
  if (true) {
    var x = 1;          // function-scoped, not block-scoped
  }
  console.log(x);       // 1 - accessible outside the if block
}

```

### Let and Const Block Scope

Both `let` and `const` are **block-scoped**, meaning they exist only within the nearest enclosing `{ … }` block, `for` loop header, or `switch` case. The same test suite confirms that attempting to access these variables outside their block throws a `ReferenceError`:

```javascript
// tests/fundamentals/scope-and-closures/scope-and-closures.test.js
it('should keep let and const within blocks', () => {
  let outsideBlock = "outside";
  {
    let blockLet = "I'm block-scoped";
    const blockConst = "Also block-scoped";
    expect(blockLet).toBe("I'm block-scoped");
  }
  // blockLet and blockConst are not defined here → ReferenceError
});

```

## Hoisting and the Temporal Dead Zone

All three keywords hoist their declarations to the top of their respective scopes, but **initialization behavior differs dramatically**.

### Var Hoisting with Undefined

When the JavaScript engine hoists a `var` declaration, it immediately initializes the variable with `undefined`. This means accessing the variable before its declaration line returns `undefined` rather than throwing an error.

From [`tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js`](https://github.com/leonardomso/33-js-concepts/blob/main/tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js):

```javascript
it('should return undefined when accessing var before declaration', () => {
  expect(x).toBeUndefined(); // x is hoisted as undefined
  var x = 10;
});

```

### Let and Const TDZ Behavior

`let` and `const` are also hoisted to the top of their block, but they remain **uninitialized** until the engine executes their declaration line. This period between the start of the block and the declaration is called the **Temporal Dead Zone (TDZ)**. Any access during the TDZ throws a `ReferenceError`.

The test suite demonstrates this strict behavior:

```javascript
// tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js
it('should throw ReferenceError when accessing let before declaration', () => {
  expect(() => y).toThrow(ReferenceError); // TDZ violation
  let y = 20;
});

```

Similarly, `const` follows the same hoisting rules as `let`, entering the TDZ until initialization occurs.

## Redeclaration and Reassignment Rules

Beyond scope and hoisting, the keywords enforce different rules for redeclaration and value mutation.

### Var Allows Silent Redeclaration

`var` permits redeclaration within the same scope without error, silently overwriting the previous variable. The repository's scope tests verify this behavior:

```javascript
// tests/fundamentals/scope-and-closures/scope-and-closures.test.js
it('should allow var redeclaration', () => {
  var name = "Alice";
  var name = "Bob";     // silent overwrite, no error
  expect(name).toBe("Bob");
});

```

In contrast, both `let` and `const` throw a `SyntaxError` if you attempt to declare the same identifier twice within the same block scope.

### Const Mutability vs Binding Immutability

While `const` prevents reassignment of the variable binding itself, it does **not** make objects immutable. You can still modify properties of objects or elements of arrays declared with `const`.

The test suite clarifies this distinction:

```javascript
// tests/fundamentals/scope-and-closures/scope-and-closures.test.js
it('should allow const object mutation but not reassignment', () => {
  const user = { name: "Alice" };
  user.name = "Bob";    // mutation works
  // user = {};         // TypeError: Assignment to constant variable
  expect(user.name).toBe("Bob");
});

```

## Practical Impact: The Classic For-Loop Problem

The scope difference creates a notorious bug when using asynchronous callbacks inside loops. Because `var` declares a single shared variable across all iterations, closures created inside the loop capture the same final value.

The repository's test file in [`tests/fundamentals/scope-and-closures/scope-and-closures.test.js`](https://github.com/leonardomso/33-js-concepts/blob/main/tests/fundamentals/scope-and-closures/scope-and-closures.test.js) documents this classic problem and its solution:

```javascript
// Problem with var - all callbacks see the same variable
it('should demonstrate var problem with setTimeout', async () => {
  const results = [];
  for (var i = 0; i < 3; i++) {
    setTimeout(() => results.push(i), 10); // all push 3
  }
  await new Promise(r => setTimeout(r, 50));
  expect(results.sort()).toEqual([3, 3, 3]);
});

// Solution with let - each iteration gets its own binding
it('should demonstrate let solution with setTimeout', async () => {
  const results = [];
  for (let i = 0; i < 3; i++) {
    setTimeout(() => results.push(i), 10); // captures current i
  }
  await new Promise(r => setTimeout(r, 50));
  expect(results.sort()).toEqual([0, 1, 2]);
});

```

Switching from `var` to `let` ensures each iteration creates a fresh binding, allowing callbacks to capture the correct value.

## Summary

- **Scope**: `var` is function-scoped and leaks out of blocks, while `let` and `const` respect block boundaries.
- **Hoisting**: `var` initializes as `undefined` immediately, but `let` and `const` enter a Temporal Dead Zone that throws `ReferenceError` on early access.
- **Redeclaration**: `var` allows silent redeclaration; `let` and `const` throw `SyntaxError` for duplicate declarations in the same block.
- **Reassignment**: `const` prevents binding reassignment but permits object mutation, while `let` allows both reassignment and mutation.
- **Best Practice**: Use `const` by default, `let` when rebinding is necessary, and avoid `var` to prevent scope leakage and hoisting confusion.

## Frequently Asked Questions

### Is let hoisted like var?

Yes, `let` is hoisted to the top of its block, but unlike `var`, it remains uninitialized in the **Temporal Dead Zone** until the declaration line executes. Accessing it before declaration throws a `ReferenceError` rather than returning `undefined`, as demonstrated in [`tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js`](https://github.com/leonardomso/33-js-concepts/blob/main/tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js).

### Can I redeclare a variable with let in the same block?

No. Attempting to declare `let a` twice within the same block scope throws a `SyntaxError`. This differs from `var`, which silently allows redeclaration and overwrites the previous value without warning.

### Why does const allow object mutation but not reassignment?

`const` creates an immutable **binding** between the variable name and its value, not an immutable value itself. When you declare `const obj = {}`, the reference to the object is locked, but the object's internal properties remain mutable. Reassigning the variable to a new reference (`obj = newObj`) throws a `TypeError`.

### Should I use var, let, or const by default?

Modern JavaScript best practices recommend using **`const`** as the default declaration. It prevents accidental reassignment and signals that the binding should not change. Use **`let`** only when you intentionally need to reassign the variable. Avoid **`var`** entirely in new code to eliminate function-scope leakage and hoisting confusion.