If you're coming to JavaScript from another language, you might be confused. Why does JavaScript have three different keywords for declaring variables? Isn't one enough?
The answer isn't just "var is old, use let/const instead." There's a fundamental difference in how these keywords interact with JavaScript's execution model - specifically, how the engine creates and manages execution contexts and lexical environments.
Let's dig deep and understand what's actually happening when you declare a variable.
What Really Happens When JavaScript Runs Your Code
Before we talk about var, let, or const, we need to understand how JavaScript prepares to run your code.
When JavaScript enters any executable context (a function, a block, or global code), it goes through two phases:
- Creation phase: Sets up the environment
- Execution phase: Actually runs your code line by line
During the creation phase, JavaScript creates something called a Lexical Environment. Think of it as a data structure that consists of:
- Environment Record: A place where variables and functions are stored
- Outer reference: A link to the parent lexical environment (for scope chain)
Here's the critical part: var, let, and const interact with these structures completely differently.
var: Function-Level Environment Records
Let's trace through what happens with var:
function checkAge() {
console.log(age); // undefined
var age = 25;
console.log(age); // 25
}
Creation Phase (before any code runs):
- JavaScript creates a Function Environment Record for
checkAge - It scans the entire function body looking for
vardeclarations - For each
var, it creates a property in the Environment Record and initializes it toundefined - The assignment (
= 25) is not executed yet
So internally, the Environment Record looks like:
FunctionEnvironmentRecord {
age: undefined // created during setup
}
Execution Phase:
-
console.log(age)- reads from Environment Record →undefined -
age = 25- updates the existing property in Environment Record -
console.log(age)- reads from Environment Record →25
This is why var is "hoisted" - it's not that the declaration moves up, it's that the variable exists in the Environment Record from the very beginning of function execution.
The Block Scope Problem: Why var Ignores Blocks
function test() {
if (true) {
var x = 10;
}
console.log(x); // 10
}
Here's what's happening under the hood:
Creation Phase:
- JavaScript creates one Function Environment Record for the entire
testfunction - It finds
var xinside theifblock - It adds
x: undefinedto the Function Environment Record - The
ifblock does not create its own Environment Record (forvar)
Execution Phase:
- Enters
ifblock -
x = 10updates thexin the Function Environment Record - Exits
ifblock (but the Environment Record stays - it's the function's record) -
console.log(x)reads from the same Function Environment Record →10
The specification doesn't give blocks their own Environment Records when you use var. Blocks are just control flow - they don't create new scopes.
The Loop Mystery: Why All Callbacks Share the Same Variable
This is where it gets really interesting:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
Let's trace the execution step by step:
What happens:
- Loop initialization creates
iin the Function Environment Record (or Global if no function) - First iteration:
i = 0, setTimeout schedules a callback - Second iteration:
i = 1, setTimeout schedules another callback - Third iteration:
i = 2, setTimeout schedules another callback - Loop condition check:
i = 3, condition fails, loop exits - ~100ms later, callbacks execute
Why they all print 3:
Each arrow function creates a closure. A closure is a function plus a reference to the Lexical Environment where it was created. All three arrow functions close over the same Lexical Environment - the one containing the loop.
When callbacks execute, they read i from that shared Environment Record. By that time, the loop has finished and i is 3.
Visually:
Outer Lexical Environment:
EnvironmentRecord: { i: 3 } // Final value after loop completes
Callback 1: () => console.log(i) ──┐
Callback 2: () => console.log(i) ──┼─→ All reference the same environment
Callback 3: () => console.log(i) ──┘
There's only one i variable in memory, shared by all callbacks.
let and const: Block-Level Environment Records
Now here's where JavaScript's behavior fundamentally changes:
{
let x = 10;
}
console.log(x); // ReferenceError
What happens:
When JavaScript encounters a block with let or const, it creates a new Lexical Environment with its own Environment Record (called a Declarative Environment Record for blocks).
Creation Phase (entering the block):
- Create a new Lexical Environment for the block
- Find all
let/constdeclarations in that block - Create uninitialized bindings in the Environment Record
- Set the outer reference to the parent environment
Execution Phase:
- When code reaches
let x = 10;, the binding is initialized - Upon exiting the block, the Lexical Environment is discarded (if no closures reference it)
The crucial difference: the variable exists in the Environment Record from the start (it's registered), but accessing it before initialization throws an error.
The Temporal Dead Zone: Not Just a Rule, It's a State
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
Here's what's really happening:
When JavaScript enters the scope, it creates a binding for x in the Environment Record, but that binding is in a special state: uninitialized. This is different from undefined.
Internally, the Environment Record might look like:
DeclarativeEnvironmentRecord {
x: <uninitialized> // Special internal state, not undefined
}
If you try to read x, JavaScript checks: "Is this binding initialized?" If not, it throws a ReferenceError. This is the TDZ - the time between when the binding is created and when it's initialized.
When execution reaches let x = 5;, the binding transitions from <uninitialized> to 5.
Why does this exist?
Consider this scenario:
let x = x + 1; // ReferenceError
Without TDZ, what would x be on the right side? undefined? That would silently create bugs (undefined + 1 = NaN). The TDZ forces an error, making bugs obvious.
The Loop Fix: Creating New Environments Per Iteration
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
This is where the specification does something really clever. For let in a for loop, JavaScript creates a new Lexical Environment for each iteration.
Here's the actual process:
Iteration 0:
- Create a new Lexical Environment:
{ i: 0 } - Execute loop body in this environment
-
setTimeoutcallback closes over this environment
Iteration 1:
- Create a new Lexical Environment:
{ i: 1 } - Execute loop body in this environment
-
setTimeoutcallback closes over this new environment
Iteration 2:
- Create a new Lexical Environment:
{ i: 2 } - Execute loop body in this environment
-
setTimeoutcallback closes over this new environment
Visually:
Environment 0: { i: 0 } ← Callback 1 references this
Environment 1: { i: 1 } ← Callback 2 references this
Environment 2: { i: 2 } ← Callback 3 references this
Each callback has its own i because each iteration creates a separate Lexical Environment. This is specified in ECMAScript §13.7.4.8 - the spec literally says to create a new environment and copy the loop variables into it.
This is not the same as:
let i = 0;
// Create environment 0
i = 1;
// Create environment 1
i = 2;
// Create environment 2
That would be just reassigning the same variable. The spec explicitly creates new bindings.
const: Immutable Bindings in the Environment Record
const x = 10;
x = 20; // TypeError: Assignment to constant variable
What's happening here at the engine level?
When you declare const x = 10, JavaScript creates a binding in the Environment Record, but marks it as immutable. Internally, this might be represented as:
DeclarativeEnvironmentRecord {
x: {
value: 10,
mutable: false // Internal flag
}
}
When you try x = 20, JavaScript checks the mutable flag. If it's false, it throws a TypeError.
Important: This is about the binding, not the value:
const obj = { count: 1 };
obj.count = 2; // OK
obj = {}; // TypeError
The binding obj is immutable (you can't make it point to something else), but the object it points to is still mutable. The Environment Record only controls the binding, not the heap-allocated object.
The Weird Interactions: Shadowing and Redeclaration
let x = 1;
{
var x = 2; // SyntaxError: Identifier 'x' has already been declared
}
Why does this fail at parse time (before execution)?
When JavaScript parses this code, it sees:
-
let x- will create a binding in the outer environment -
var x- tries to create a binding in... wait, where?
var is function-scoped, so it would try to create x in the same Environment Record as the outer let x. But the spec says you can't have duplicate lexical declarations in the same scope. Parse error.
But this works:
var x = 1;
{
let x = 2; // OK
console.log(x); // 2
}
console.log(x); // 1
Because let x creates a binding in the block's Environment Record, which is different from the function/global Environment Record where var x lives.
The environment chain looks like:
Block Environment:
Record: { x: 2 }
Outer → Function/Global Environment:
Record: { x: 1 }
When you access x inside the block, JavaScript looks in the innermost environment first (finds 2). Outside the block, it looks in the function/global environment (finds 1).
What About Performance?
You might wonder: if let creates new environments for each loop iteration, isn't that slower?
Modern JavaScript engines (V8, SpiderMonkey, JavaScriptCore) optimize this heavily. If a loop variable isn't captured by a closure, the engine doesn't actually create separate environments - it's smart enough to know the variable isn't shared.
The extra environments only exist when they're semantically necessary (when closures capture them).
Summary: It's About Lexical Environments
The real difference between var, let, and const isn't syntax - it's how they interact with JavaScript's Lexical Environment system:
-
var: Creates bindings in Function Environment Records, initialized to
undefinedimmediately, function-scoped - let: Creates bindings in Block Environment Records, starts in TDZ, block-scoped, one environment per loop iteration
-
const: Same as
let, but bindings are marked immutable
Understanding this helps you predict behavior and debug issues. When you see strange scoping bugs, you can trace through the environment creation and see exactly where a variable lives.
Next time you write let in a loop, you'll know that JavaScript is creating separate environments behind the scenes - and why that matters for closures.
In the next article we'll see why closures aren't feature - they're a consequence of JavaScript's execution model.
Stay tuned for every Sunday!
Top comments (0)