02 — Variable Binding, TDZ Internals & Type Coercion Engine
let / const / var: Scope and Hoisting Internals
javascript
// ── All three are hoisted — the difference is initialization ──
// var: hoisted + initialized to undefined (accessible before declaration, value is undefined)
// let: hoisted but NOT initialized → Temporal Dead Zone (TDZ) → ReferenceError if accessed early
// const: same as let, but the binding is immutable (can't reassign the variable)
// ── var: function-scoped (or global) — not block-scoped ──
function varScope() {
if (true) {
var x = 10; // function-scoped: x exists in entire function
}
console.log(x); // 10 — var leaks out of the block
}
// var x is visible from the point of declaration to the END of the function
// (not just the block it's declared in)
// ── let/const: block-scoped (lexical block scope) ──
function letScope() {
if (true) {
let y = 20; // block-scoped: y only exists in this if-block
}
console.log(y); // ReferenceError: y is not defined
}
// ── Hoisting: all declarations are moved to the top of their scope ──
// But let/const go into the TDZ (not initialized):
console.log(v); // undefined (var is hoisted + initialized to undefined)
var v = 5;
console.log(l); // ReferenceError: Cannot access 'l' before initialization (TDZ!)
let l = 5;
// ── The TDZ is a region from the start of the scope to the declaration ──
{
// TDZ for x starts here (block opening)
console.log(x); // ReferenceError — in the TDZ
let x = 10; // TDZ ends — x is now accessible
console.log(x); // 10
}
// ── const: immutable binding, not immutable value ──
const obj = { x: 1 };
obj.x = 2; // ✓ the object is mutable — const only prevents reassignment
// obj = { x: 3 }; // ✗ TypeError: Assignment to constant variable
console.log(obj.x); // 2
// ── var in the global scope creates a property on the global object ──
var globalVar = 10;
console.log(globalThis.globalVar); // 10 — var on global scope → window/globalThis property
let globalLet = 20;
console.log(globalThis.globalLet); // undefined — let/const do NOT create global properties
Anti-Pattern: var in Loops (Closure Capture Bug)
javascript
// ❌ NAIVE — var is function-scoped, so the closure captures the SAME variable
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Output: 3, 3, 3 — all closures share the SAME i (function-scoped, final value is 3)
// ✅ CORRECT — let is block-scoped, each iteration gets its own binding
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Output: 0, 1, 2 — each iteration's let creates a new binding (per-iteration scope)
// ✅ IIFE workaround (pre-ES6, when let didn't exist)
for (var i = 0; i < 3; i++) {
(function(j) { // IIFE creates a new scope, captures the current value
setTimeout(() => console.log(j), 0);
})(i); // pass current i as argument → j is a copy
}
// Output: 0, 1, 2
// ── How let in loops actually works ──
// `for (let i = 0; i < 3; i++)` is desugared to create a NEW binding per iteration:
// { let i = 0; setTimeout(() => console.log(i), 0); }
// { let i = 1; setTimeout(() => console.log(i), 0); }
// { let i = 2; setTimeout(() => console.log(i), 0); }
// Each iteration has its own `i` in its own scope.
Primitive vs Reference: Memory Model
javascript
// ── 7 primitive types (stored by value, immutable) ──
// string, number, boolean, null, undefined, symbol, bigint
// Primitives are copied by value — each variable gets its own copy.
let str = "hello";
let strCopy = str; // copy: strCopy is a separate "hello"
str = "world";
console.log(strCopy); // "hello" — unchanged (primitives are immutable copies)
let num = 42;
let numCopy = num;
num = 99;
console.log(numCopy); // 42 — unchanged
// ── Reference types (stored by reference, mutable) ──
// Object, Array, Function, Date, RegExp, Map, Set, etc.
// References point to heap-allocated objects — copying a reference shares the object.
const arr = [1, 2, 3];
const arrRef = arr; // arrRef points to the SAME array (copy of the reference)
arr.push(4);
console.log(arrRef); // [1, 2, 3, 4] — shared mutation
// ── Passing to functions: primitives copy value, references share object ──
function modifyPrim(n) { n = 99; } // n is a copy — modifying it doesn't affect caller
function modifyRef(o) { o.x = 99; } // o is a reference — mutating the object affects caller
let x = 10;
modifyPrim(x);
console.log(x); // 10 — unchanged (primitive was copied)
let obj = { x: 10 };
modifyRef(obj);
console.log(obj.x); // 99 — changed (shared object was mutated)
// ── Reassigning a reference inside a function does NOT affect the caller ──
function reassign(o) { o = { x: 99 }; } // o is a local reference, now points to a NEW object
let myObj = { x: 10 };
reassign(myObj);
console.log(myObj.x); // 10 — unchanged (the local o was reassigned, not the caller's variable)
Type Coercion: The Abstract Equality Algorithm
javascript
// ── `==` triggers type coercion; `===` checks type first ──
// The Abstract Equality Algorithm (ECMA-262 §7.2.15) has ~20 rules.
// Key rules (the most surprising ones):
// Rule 1: number ↔ string → string is coerced to number
1 == "1"; // true — "1" → 1 (ToNumber("1") = 1)
0 == ""; // true — "" → 0 (ToNumber("") = 0)
0 == "0"; // true — "0" → 0
// Rule 2: boolean → number (true → 1, false → 0)
1 == true; // true — true → 1
0 == false; // true — false → 0
"" == false; // true — "" → 0, false → 0 → 0 == 0
// Rule 3: null == undefined (special case — only each other)
null == undefined; // true (special rule — they're "loosely equal")
null == 0; // false (null is NOT coerced to 0 for ==)
null == false; // false
// Rule 4: object → primitive (via ToPrimitive, which calls valueOf/toString)
"[object Object]" == {}; // true — {} → "[object Object]"
"1,2,3" == [1, 2, 3]; // true — array → "1,2,3" → "1,2,3" == "1,2,3"
"1" == [1]; // true — [1] → "1" → 1 == 1
// ── NaN: the only value not equal to itself ──
NaN == NaN; // false
NaN === NaN; // false
Number.isNaN(NaN); // true (correct — doesn't coerce)
// ── `===` is always safer: no coercion, checks type first ──
1 === "1"; // false (different types — immediate false)
null === undefined; // false (different types)
// ── Object.is: the "truly correct" equality (handles NaN and -0) ──
Object.is(NaN, NaN); // true (unlike == and ===)
Object.is(-0, 0); // false (unlike === which says -0 === 0 → true)
Object.is(0, 0); // true
Anti-Pattern: Implicit Coercion in Conditionals
javascript
// ❌ NAIVE — relies on implicit coercion (surprising results)
function process(data) {
if (data) { // empty array [] is truthy! empty string "" is falsy
doWork(data);
}
}
process([]); // [] is truthy — doWork runs with empty array (probably not intended)
process(""); // "" is falsy — doWork doesn't run (maybe intended, maybe not)
// ✅ CORRECT — explicit checks
function processSafe(data) {
if (data != null && typeof data === "object" && data.length > 0) {
doWork(data); // explicit: not null, is object, has elements
}
}
// ── The 8 falsy values ──
// false, 0, -0, 0n, "", null, undefined, NaN
// Everything else is truthy (including [], {}, "0", "false", Infinity)
// ── ⚠️ `0` and `""` are falsy — `??` vs `||` matters ──
let count = 0;
let default1 = count || 10; // 10 — || treats 0 as falsy, uses default
let default2 = count ?? 10; // 0 — ?? only uses default for null/undefined
let name = "";
let n1 = name || "Anonymous"; // "Anonymous" — || treats "" as falsy
let n2 = name ?? "Anonymous"; // "" — ?? keeps empty string
// ── `??` (nullish coalescing) only triggers on null/undefined ──
// Use ?? for "optional with fallback"; use || for "falsy with fallback"
let port = config.port ?? 3000; // 3000 only if config.port is null/undefined
let mode = config.mode || "prod"; // "prod" if config.mode is any falsy value
Primitive Type Checking: The Reliable Way
javascript
// ── typeof: works for primitives, unreliable for objects ──
typeof "str"; // "string"
typeof 42; // "number"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof null; // "object" ⚠️ BUG (null is primitive, not object)
typeof Symbol(); // "symbol"
typeof 42n; // "bigint"
typeof {}; // "object"
typeof []; // "object" ⚠️ arrays are objects
typeof function(){}; // "function"
typeof null; // "object" ⚠️ legendary bug
// ── Reliable type checks ──
const type = (val) => {
if (val === null) return "null";
if (Array.isArray(val)) return "array";
return typeof val;
};
type(null); // "null"
type([]); // "array"
type({}); // "object"
type(42); // "number"
// ── Object.prototype.toString (the most reliable) ──
Object.prototype.toString.call(""); // "[object String]"
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call(new Map()); // "[object Map]"
Object.prototype.toString.call(async function(){}); // "[object AsyncFunction]"
// ── instanceof: checks the prototype chain (reference types only) ──
[] instanceof Array; // true
[] instanceof Object; // true (Array extends Object — prototype chain)
({}) instanceof Object; // true
new Map() instanceof Map; // true
// ⚠️ instanceof breaks across realms (iframes, worker threads)
// Each realm has its own copy of Array → an array from an iframe is NOT instanceof Array
// Use Array.isArray() for arrays — works across realms
Number Internals: IEEE 754 Double Precision
javascript
// ── JS numbers are IEEE 754 double-precision floats (64-bit) ──
// 1 bit sign, 11 bits exponent, 52 bits mantissa (53-bit significand)
// Safe integer range: -(2^53 - 1) to (2^53 - 1) = -9007199254740991 to 9007199254740991
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991 (2^53 - 1)
console.log(Number.MIN_SAFE_INTEGER); // -9007199254740991
console.log(Number.MAX_VALUE); // 1.7976931348623157e+308
console.log(Number.EPSILON); // 2.220446049250313e-16
// ── Precision loss beyond MAX_SAFE_INTEGER ──
console.log(9007199254740991 + 1); // 9007199254740992 (correct)
console.log(9007199254740991 + 2); // 9007199254740992 (WRONG! precision lost)
console.log(9007199254740993 === 9007199254740992); // true (can't distinguish)
// ── 0.1 + 0.2 !== 0.3 (classic floating-point bug) ──
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
// ✅ Fix: use Number.EPSILON for comparison
function approxEqual(a, b, eps = Number.EPSILON) {
return Math.abs(a - b) < eps;
}
console.log(approxEqual(0.1 + 0.2, 0.3)); // true
// ✅ Fix for money: use integer cents or BigDecimal library
function addMoney(a, b) {
return (Math.round(a * 100) + Math.round(b * 100)) / 100; // work in cents
}
console.log(addMoney(0.1, 0.2)); // 0.3
// ── BigInt: arbitrary-precision integers (no precision loss) ──
const big = 9007199254740993n; // n suffix → BigInt
console.log(big + 1n); // 9007199254740994n (correct!)
// ⚠️ BigInt can't mix with regular numbers: big + 1 → TypeError
// BigInt is for integers only — no decimals: 1n / 2n → 0n (truncated)
// ── -0 exists (negative zero) ──
console.log(-0 === 0); // true (=== can't distinguish)
console.log(Object.is(-0, 0)); // false (Object.is can)
console.log(1 / -0); // -Infinity (negative zero matters for division)
console.log(Math.sign(-0)); // -1 (Math.sign distinguishes -0)
💡 Tips & Tricks
javascript
// ── Swap variables with destructuring (no temp needed) ──
let a = 1, b = 2;
[a, b] = [b, a]; // a=2, b=1
// ── Optional chaining with nullish coalescing for deep defaults ──
const port = config?.server?.port ?? 3000; // 3000 if any part is null/undefined
// ── Comma operator: evaluate multiple expressions, return last ──
const result = (console.log("side effect"), 42); // logs, then result = 42
// Use sparingly — can be confusing. Useful in for-loop init: for (let i = 0, j = 10; ...)
// ── Numeric separators (ES2021) for readability ──
const billion = 1_000_000_000; // underscores ignored — same as 1000000000
const bytes = 0xff_ff; // hex with separators
const binary = 0b1010_1010; // binary with separators
// ── Tagged template literals for safe HTML/SQL ──
function html(strings, ...values) {
return strings.reduce((acc, str, i) =>
acc + str + (i < values.length ? String(values[i]).replace(/[<>&]/g, c =>
({'<':'<','>':'>','&':'&'}[c])) : ''), '');
}
const userInput = "<script>alert('xss')</script>";
const safe = html`<div>${userInput}</div>`; // escaped: <script>...
⚠️ Edge Cases & Gotchas
javascript
// ── `typeof` for undeclared variables doesn't throw (but let/const in TDZ does) ──
console.log(typeof undeclaredVar); // "undefined" (no error — typeof is safe for undeclared)
// console.log(typeof tdzVar); // ReferenceError if tdzVar is in TDZ (let/const)
// ── `const` with arrays/objects: the binding is frozen, the value isn't ──
const arr = [1, 2, 3];
arr.push(4); // ✓ mutates the array (const only prevents reassigning `arr`)
// arr = []; // ✗ TypeError: Assignment to constant variable
// ── `let` in the global scope does NOT create a global object property ──
let x = 10;
console.log(window.x); // undefined (let doesn't create window property)
var y = 10;
console.log(window.y); // 10 (var creates window property)
// ── Re-declaration: var allows it, let/const don't ──
var a = 1;
var a = 2; // ✓ var allows re-declaration
let b = 1;
// let b = 2; // ✗ SyntaxError: Identifier 'b' has already been declared
// ── `0.1 + 0.2` and the `toFixed` trap ──
(0.1 + 0.2).toFixed(2); // "0.30" (toFixed rounds to string)
// ⚠️ toFixed returns a STRING, not a number
// ⚠️ toFixed has rounding issues: (1.005).toFixed(2) → "1.00" (not "1.01"!)
🧠 Quick Quiz
What does this output?
javascript
let x = 1;
function check() {
if (false) {
let x = 2;
}
console.log(x);
}
check();
Answer
1
The let x = 2 inside the if (false) block is block-scoped to that if block. Even though the block never executes, the let declaration still creates a binding in that block's scope (in the TDZ). But since console.log(x) is in the function scope (outside the if block), it accesses the outer let x = 1.
If the console.log(x) were inside the if block (before the let x = 2), it would throw ReferenceError (TDZ — accessing x before its declaration in the same block).
The lesson: let/const are block-scoped. A let inside an if (false) block still creates a binding in that block's scope — but only that block. The outer x is unaffected.