var vs let vs const in JavaScript: Scope, Hoisting and the Temporal Dead Zone

Trace block scope, early reads, loop callbacks, and const object mutation using the rules behind common JavaScript output questions.

KnowledgeGate Team

Exam prep & CS education

Updated 19 Aug 20265 min read

var, let, and const output questions look like tricks, but they follow fixed rules about scope and hoisting. Memorising “prefer const” is not enough when an interviewer asks why var escaped a block or why reading let before its declaration threw an error. Build the rules first, then trace any snippet line by line.

Three declarations, three behaviours

Five behaviours separate them, and almost every output question turns on one of the five:

Behaviour

var

let

const

Scope

Function or global

Block

Block

Hoisting

Hoisted and initialised to undefined

Hoisted but uninitialised in the TDZ

Hoisted but uninitialised in the TDZ

Same-scope redeclaration

Allowed

Syntax error

Syntax error

Reassignment

Allowed

Allowed

Not allowed

Initial value required

No

No

Yes

In a classic browser script, a top-level var x also creates a property on window. Top-level let and const do not. Modules have their own top-level scope, and Node.js has a different wrapper model, so do not turn the browser-script rule into a universal claim.

The modern default is const for a binding that will not be reassigned and let when reassignment is intentional. Understanding var still matters because existing code and interview snippets use it heavily.

Function scope versus block scope

A block is the region inside braces for constructs such as if, for, and while. let and const stay inside that region. var ignores the block boundary and belongs to the nearest function.

function f() {
  if (true) {
    var x = 1;
    let y = 2;
  }

  console.log(x);
  console.log(y);
}

f();

The first console.log prints 1 because x belongs to the whole function. The second reaches for y outside its block, where no binding exists at all, so it throws ReferenceError: y is not defined. Execution stops at that line. Hold on to that exact message, because reading a let binding too early throws a different one.

Braces alone do not constrain var:

{
  var visible = "outside too";
  const local = "inside only";
}

This leakage is one reason block-scoped declarations make refactoring safer. A temporary loop or condition variable cannot silently overwrite another binding elsewhere in the function.

Hoisting and the Temporal Dead Zone

JavaScript creates bindings before it executes the statements in a scope. That setup is commonly called hoisting, but the three declarations are not initialised in the same way.

console.log(a);
var a = 1;

The var a binding exists from the start of the scope and is initialised to undefined. The log therefore prints undefined; assignment to 1 happens later.

Now compare let:

console.log(b);
let b = 2;

The binding also exists from the start of its block, but it remains uninitialised until execution reaches the declaration. Reading it in that interval throws ReferenceError: Cannot access 'b' before initialization. That interval is the Temporal Dead Zone, or TDZ. const follows the same early-read rule.

The TDZ explains why “let is not hoisted” is an inaccurate shortcut. If there were no binding, scope lookup might continue outward. Instead, the inner uninitialised binding blocks access until its declaration runs.

Even typeof does not bypass the TDZ:

typeof missingName; // "undefined" if no such binding exists
typeof tdzName;     // ReferenceError
let tdzName = 1;

The loop closure trap

Callbacks created in a loop make the scope difference visible.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

There is one function-scoped i shared by all three arrows. The loop schedules three callbacks while i takes values 0, 1, and 2. The increment after the third iteration changes it to 3, the condition 3 < 3 fails, and only then do the timer callbacks run. All three read the same final binding, so the output is 3, 3, 3.

Use let instead:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

The loop creates a fresh i binding for each iteration. The three closures capture 0, 1, and 2 separately, so the output is 0, 1, 2.

Count the bindings and both outputs follow. Three iterations create three callbacks. With var, one shared cell ends at 3, producing three copies of 3. With let, three cells hold one value each, producing 0, 1, and 2.

Two-panel comparison: with var, three setTimeout closures share one i that ends at 3 and all print 3; with let, each closure holds its own i, printing 0, 1 and 2.

What const actually makes constant

const prevents reassignment of the binding. It does not recursively freeze the value stored in an object.

const arr = [1, 2];
arr.push(3);     // allowed
console.log(arr); // [1, 2, 3]

arr = [];        // TypeError: Assignment to constant variable

The array object can still be mutated, so push succeeds. The name arr cannot be redirected to a different array, so reassignment fails. Likewise, properties of a const object can change unless another mechanism prevents it.

Object.freeze(value) provides shallow freezing: direct properties cannot be changed in the usual way, but nested objects are not automatically frozen. Also, const x; raises SyntaxError: Missing initializer in const declaration, because a constant binding must receive its value at the declaration itself.

Scope and hoisting traps interviewers use

  • Repeating var x in the same function is allowed, which can hide an accidental redeclaration. Repeating let x or const x in the same scope is a syntax error.

  • A var declared at top level becomes a window property only in the classic browser-script setting, not in an ES module.

  • typeof throws when the chosen binding exists but is still in its TDZ.

  • A var loop callback reads the final shared value unless another scope is introduced.

  • A function declaration is available with its function value during scope setup. A function expression assigned to var has only the variable initialised to undefined before assignment, so calling it early throws a TypeError.

These are the same output-reading habits tested in JavaScript interview questions for freshers. For the closure machinery underneath the loop trap, including two correct ways to fix the var version, work through Closures in JavaScript: lexical scope and the var loop trap.

The short version and next step

var is function-scoped and starts as undefined during scope setup. let and const are block-scoped and cannot be read in their TDZ. const protects a binding from reassignment, not the internals of an object.

The KnowledgeGate question bank carries over 70 JavaScript questions built on these snippet patterns. Follow the wider Placement Preparation category, build the language foundation in Complete JavaScript, or practise along the Mera Placement Hoga bundle. For every output question, mark the scope, creation point, initialisation point, and execution order before answering.