DEV Community

Cover image for var, let, const: Why JavaScript Needed Three Ways to Declare Variables
Razumovsky
Razumovsky

Posted on

var, let, const: Why JavaScript Needed Three Ways to Declare Variables

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:

  1. Creation phase: Sets up the environment
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

Creation Phase (before any code runs):

  1. JavaScript creates a Function Environment Record for checkAge
  2. It scans the entire function body looking for var declarations
  3. For each var, it creates a property in the Environment Record and initializes it to undefined
  4. The assignment (= 25) is not executed yet

So internally, the Environment Record looks like:

FunctionEnvironmentRecord {
  age: undefined  // created during setup
}
Enter fullscreen mode Exit fullscreen mode

Execution Phase:

  1. console.log(age) - reads from Environment Record → undefined
  2. age = 25 - updates the existing property in Environment Record
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

Here's what's happening under the hood:

Creation Phase:

  • JavaScript creates one Function Environment Record for the entire test function
  • It finds var x inside the if block
  • It adds x: undefined to the Function Environment Record
  • The if block does not create its own Environment Record (for var)

Execution Phase:

  • Enters if block
  • x = 10 updates the x in the Function Environment Record
  • Exits if block (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
Enter fullscreen mode Exit fullscreen mode

Let's trace the execution step by step:

What happens:

  1. Loop initialization creates i in the Function Environment Record (or Global if no function)
  2. First iteration: i = 0, setTimeout schedules a callback
  3. Second iteration: i = 1, setTimeout schedules another callback
  4. Third iteration: i = 2, setTimeout schedules another callback
  5. Loop condition check: i = 3, condition fails, loop exits
  6. ~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)  ──┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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):

  1. Create a new Lexical Environment for the block
  2. Find all let/const declarations in that block
  3. Create uninitialized bindings in the Environment Record
  4. Set the outer reference to the parent environment

Execution Phase:

  1. When code reaches let x = 10;, the binding is initialized
  2. 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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a new Lexical Environment: { i: 0 }
  2. Execute loop body in this environment
  3. setTimeout callback closes over this environment

Iteration 1:

  1. Create a new Lexical Environment: { i: 1 }
  2. Execute loop body in this environment
  3. setTimeout callback closes over this new environment

Iteration 2:

  1. Create a new Lexical Environment: { i: 2 }
  2. Execute loop body in this environment
  3. setTimeout callback 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Why does this fail at parse time (before execution)?

When JavaScript parses this code, it sees:

  1. let x - will create a binding in the outer environment
  2. 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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 undefined immediately, 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)