var's scope boundary is the nearest function, not the nearest block. Block braces {} don't create a new var scope.
if (true) { var x = 1; }
console.log(x) // 1 β var escaped the block
Block braces {} are invisible to var.
Parsed as var b = (a = 5). a = 5 assigns to an undeclared variable, creating an implicit global. Only b is actually declared.
function f() { var b = a = 5; }
f();
typeof a // 'number' β a leaked to global scope!
typeof b // 'undefined' β b is local
Chained assignment only declares the leftmost variable.
const locks the reference (the pointer), not the object the pointer points to. The object's properties can still be changed freely.
const o = { x: 1 };
o.x = 2; // works β mutating the object
o = {}; // TypeError β reassigning the binding
const locks the arrow, not what it points to.
let/const are hoisted to the top of their block and immediately enter the TDZ. They shadow any outer variable of the same name for the ENTIRE block β including lines before the declaration.
let x = 'outer';
{
console.log(x); // ReferenceError β inner x is in TDZ
let x = 'inner';
}
Inner let shadows outer from very first line of the block.
var creates one binding in the enclosing function scope. All closures capture the same i. By the time setTimeout fires, the loop has finished and i = 3.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Logs: 3 3 3 β not 0 1 2
// Fix: replace var with let (creates a new binding per iteration)
var + closure + loop = classic bug. Fix: let.
var declarations at the top level attach to the global object (window). let/const are stored in a separate Declarative Environment Record that has no connection to window.
var a = 1; window.a // 1 β var is on the global object let b = 2; window.b // undefined β let is NOT on window
var owns a desk in the global office. let rents a separate room.
for...of creates a fresh binding per iteration β const gets a new slot each time. Traditional for reuses the same binding and i++ would attempt a reassignment, which throws for const.
for (const x of [1, 2, 3]) { } // ok β fresh binding each iteration
for (const i = 0; i < 3; i++) { } // TypeError β i++ reassigns a const
for...of: fresh const per iteration. Traditional for: reassigns.
The catch parameter is treated as a binding scoped to the catch block. Declaring let e inside the same block is a duplicate declaration and a SyntaxError.
try {
throw 1;
} catch (e) {
let e = 2; // SyntaxError β e already declared by catch
}
catch(e) declares e. No redeclaring inside the catch block.
ESM creates its own module scope β a Declarative Environment Record separate from the global object. Top-level declarations are module-private even when using var.
// In an ES module (.mjs or <script type="module">): var x = 1; console.log(globalThis.x); // undefined β not on global object
ESM: ALL top-level vars stay in module scope. None attach to globalThis.
In sloppy mode, function declarations in blocks are hoisted to the enclosing function scope (legacy behaviour). In strict mode (or ESM), they are block-scoped. Avoid both β use const fn = () => {} for block-local functions.
// sloppy mode:
{ function f() { return 1; } }
console.log(f()); // 1 β leaked to enclosing scope
// strict mode:
'use strict';
{ function g() { return 1; } }
console.log(typeof g); // 'undefined' β block-scoped
Function in block: strict=block-scoped, sloppy=hoisted. Always use const fn = () => {} instead.
eval() runs code in the current scope. New var declarations escape into the surrounding function (or global). In strict mode, eval() is sandboxed β new vars stay inside.
function f() {
eval('var x = 1');
console.log(x); // 1 β x created in f's scope!
}
// strict mode:
'use strict';
function g() {
eval('var x = 1');
console.log(typeof x); // 'undefined' β x sandboxed inside eval
}
eval sloppy = scope injection. eval strict = sandboxed. Avoid eval entirely.