4. Variables and scope: var/let/const, hoisting, the TDZ
var a = 1; // function-scoped, hoisted, re-declarable — avoid in new code
let b = 2; // block-scoped, hoisted into a "temporal dead zone", not re-declarable
const c = 3; // block-scoped like let, binding cannot be reassigned (contents still mutable)
const arr = [1, 2];
arr.push(3); // fine — the array's contents mutate
arr = []; // TypeError — the binding `arr` is const
Hoisting: declarations are processed before code runs, but var and
let/const behave differently:
console.log(x); // undefined (not an error) — var is hoisted AND initialized to undefined
var x = 5;
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5; // let/const are hoisted but NOT initialized — this gap is the
// "Temporal Dead Zone" (TDZ), and it's a deliberate safety net
The classic var-in-a-loop closure bug, and why let fixed it:
// var: all three callbacks share ONE function-scoped `i`, which is 3 by the time they run
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // logs 3, 3, 3
}
// let: each iteration gets its OWN block-scoped binding of `i`
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // logs 0, 1, 2
}
Closures: a function "closes over" the variables in its surrounding
scope, keeping them alive even after that scope has returned — this is the
mechanism behind the let example above, and behind every module pattern,
memoizer, and event handler that references outer state.
function makeCounter() {
let count = 0; // captured by the closure below
return function increment() {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2 — `count` persisted between calls, private to this closure