Var vs Let vs Const: Scope and Hoisting Differences Explained
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 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:
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:
// 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:
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:
// 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:
// 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:
// 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 documents this classic problem and its solution:
// 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:
varis function-scoped and leaks out of blocks, whileletandconstrespect block boundaries. - Hoisting:
varinitializes asundefinedimmediately, butletandconstenter a Temporal Dead Zone that throwsReferenceErroron early access. - Redeclaration:
varallows silent redeclaration;letandconstthrowSyntaxErrorfor duplicate declarations in the same block. - Reassignment:
constprevents binding reassignment but permits object mutation, whileletallows both reassignment and mutation. - Best Practice: Use
constby default,letwhen rebinding is necessary, and avoidvarto 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.
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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →