03 — Operator Semantics, Coercion Traps & Short-Circuit Evaluation
Comparison Operators: == Coercion Algorithm
javascript
// ── `==` (loose equality) coerces operands; `===` (strict) checks type first ──
// The Abstract Equality Algorithm (ECMA-262 §7.2.15) has ~20 rules.
// ── The coercion hierarchy (simplified) ──
// 1. Same type → direct comparison (like ===)
// 2. null == undefined → true (special case)
// 3. number ↔ string → string to number (ToNumber)
// 4. boolean → number (true→1, false→0), then re-compare
// 5. object ↔ primitive → object to primitive (ToPrimitive: valueOf then toString)
// ── Rule 3: number ↔ string ──
1 == "1"; // true — ToNumber("1") = 1
0 == ""; // true — ToNumber("") = 0
0 == "0"; // true — ToNumber("0") = 0
" 1 " == 1; // true — ToNumber(" 1 ") = 1 (whitespace trimmed)
// ── Rule 4: boolean coerced to number FIRST ──
1 == true; // true — true→1, then 1==1
0 == false; // true — false→0, then 0==0
"" == false; // true — false→0, ToNumber("")→0, then 0==0
"0" == false; // true — false→0, ToNumber("0")→0, then 0==0
// ── Rule 5: object → primitive ──
"[object Object]" == {}; // true — {} → toString() → "[object Object]"
"1,2,3" == [1, 2, 3]; // true — [1,2,3] → toString() → "1,2,3"
"1" == [1]; // true — [1] → "1" → ToNumber("1")=1 → 1==1
"1" == { valueOf: () => 1 }; // true — valueOf()→1 → 1==1
// ── null and undefined: only equal to each other ──
null == undefined; // true (special case)
null == 0; // false (null is NOT coerced to 0 for ==)
null == false; // false
undefined == 0; // false
// ── `===` never coerces (always safe) ──
1 === "1"; // false (different types → immediate false)
null === undefined; // false (different types)
// ── Object.is: handles NaN and -0 correctly ──
Object.is(NaN, NaN); // true (== and === both say false!)
Object.is(-0, 0); // false (=== says true!)
Object.is(0, 0); // true
Anti-Pattern: == with Mixed Types
javascript
// ❌ NAIVE — == coercion leads to surprising bugs
if (x == "") { ... } // also matches 0, false, [], null (if x is 0, this is true!)
if (x == 0) { ... } // also matches "", false, [], "0"
if (x == null) { ... } // matches both null AND undefined (might be intended)
// ✅ CORRECT — use === for everything, and explicit checks for special cases
if (x === "") { ... } // only empty string
if (x === 0) { ... } // only zero
if (x === null || x === undefined) { ... } // explicit null/undefined check
if (x == null) { ... } // OK — == null is the idiomatic shortcut for null||undefined
// ── The ONE acceptable use of ==: checking null/undefined together ──
if (x == null) { ... } // matches null OR undefined (not 0, not "", not false)
// This is the ONLY `==` idiom most linters allow (eslint: "eqeqeq" with "allowNull")
Arithmetic: Remainder vs Modulo, Integer Division
javascript
// ── `%` is REMAINDER, not modulo (different for negative numbers) ──
// Remainder: result has the SIGN OF THE DIVIDEND
// Modulo: result has the SIGN OF THE DIVISOR
console.log(7 % 3); // 1 (same for both: 7 = 3*2 + 1)
console.log(-7 % 3); // -1 (remainder: sign of dividend → -1)
console.log(7 % -3); // 1 (remainder: sign of dividend → +1)
// Modulo would give: -7 mod 3 = 2 (sign of divisor), 7 mod -3 = -2
// ✅ True modulo function (always positive for positive divisor):
function mod(n, m) {
return ((n % m) + m) % m; // adjust negative remainder to positive
}
console.log(mod(-7, 3)); // 2 (true modulo)
// ── Integer division (no truncation toward zero like C) ──
// JS has no `//` operator. Math.trunc, Math.floor, Math.ceil differ for negatives:
console.log(Math.trunc(-7 / 3)); // -2 (truncates toward zero)
console.log(Math.floor(-7 / 3)); // -3 (rounds down)
console.log(Math.ceil(-7 / 3)); // -2 (rounds up)
// ✅ Use Math.trunc for integer division (matches C/Java behavior):
function intDiv(a, b) { return Math.trunc(a / b); }
// For floor division (Python behavior): Math.floor(a / b)
// ── `**` exponentiation (ES2016) ──
console.log(2 ** 10); // 1024
console.log(2 ** -1); // 0.5 (fractional exponent → float)
console.log((-2) ** 2); // 4 (parentheses needed for negative base: -2**2 → SyntaxError)
console.log(4 ** 0.5); // 2 (square root via fractional exponent)
console.log(8 ** (1/3)); // 2 (cube root — but floating-point: 1.9999999999999998)
// ── Bitwise operators work on 32-bit integers (coercion + truncation) ──
console.log(5 | 0); // 5 (ToInt32(5) = 5)
console.log(5.9 | 0); // 5 (truncated to integer)
console.log(-5.9 | 0); // -5 (truncated toward zero, not floor)
console.log(2147483648 | 0); // -2147483648 (overflow: 2^31 wraps to -2^31)
// ⚠️ Bitwise ops convert to 32-bit signed integers — precision loss for large numbers!
// Use Math.trunc or Math.floor for large numbers (they preserve double precision).
Bitwise Operations: 32-Bit Internals
javascript
// ── Bitwise operators coerce operands to 32-bit signed integers ──
// AND (&), OR (|), XOR (^), NOT (~), left shift (<<), signed right shift (>>)
// unsigned right shift (>>>)
// ── ToInt32 algorithm (internal) ──
// 1. ToNumber(value)
// 2. If NaN/±Infinity → 0
// 3. Take modulo 2^32
// 4. If >= 2^31 → subtract 2^32 (wrap to negative)
// ── ~ (NOT): two's complement negation ──
console.log(~0); // -1 (~0 = -(0+1) = -1)
console.log(~1); // -2 (~1 = -(1+1) = -2)
console.log(~-1); // 0 (~(-1) = 0) — useful: ~x === 0 only when x is -1
// ── `~str.indexOf(x)` idiom (pre-includes): truthy if found ──
// Before .includes() (ES2015), the idiom was:
if (~"hello".indexOf("ell")) { /* found */ } // ~2 = -3 (truthy)
if (~"hello".indexOf("xyz")) { /* not found */ } // ~(-1) = 0 (falsy)
// Modern: if ("hello".includes("ell")) — cleaner
// ── `>>> 0` to convert to unsigned 32-bit integer ──
console.log(-1 >>> 0); // 4294967295 (0xFFFFFFFF as unsigned)
console.log(3.7 >>> 0); // 3 (truncated to integer)
console.log("42" >>> 0); // 42 (coerced to number, then to uint32)
// ── Flags with bitwise OR ──
const READ = 1, WRITE = 2, EXEC = 4;
let perms = 0;
perms |= READ | WRITE; // 3 (binary: 011)
perms & READ ? "readable" : "not"; // "readable" (bit is set)
perms & EXEC ? "executable" : "not"; // "not" (bit not set)
perms &= ~WRITE; // clear WRITE bit → 1 (binary: 001)
perms & WRITE ? "writable" : "not"; // "not" (cleared)
// ── << for fast power-of-2 multiplication ──
console.log(1 << 10); // 1024 (2^10 — fast, but limited to 32-bit range)
console.log(1 << 31); // -2147483648 (overflow: MSB is sign bit → negative)
// For larger: use ** or BigInt
Logical Operators: Short-Circuit Semantics
javascript
// ── && (AND): returns the FIRST falsy operand, or the LAST if all truthy ──
console.log(0 && "never"); // 0 (0 is falsy — returns 0, "never" not evaluated)
console.log(1 && "reached"); // "reached" (all truthy — returns last)
console.log("" && 0); // "" (first falsy — empty string)
console.log(null && 1); // null (first falsy)
// ── || (OR): returns the FIRST truthy operand, or the LAST if all falsy ──
console.log(0 || "default"); // "default" (0 is falsy → returns next)
console.log("x" || "default"); // "x" (first truthy → returns it)
console.log(null || 0 || ""); // "" (all falsy → returns last)
// ── ⚠️ && and || return the OPERAND VALUE, not a boolean ──
// They don't coerce to true/false — they return the actual value of an operand.
const result = 1 && "hello"; // "hello" (string, not boolean true)
// ── Short-circuit: the right side is only evaluated if needed ──
function sideEffect() { console.log("evaluated"); return true; }
false && sideEffect(); // sideEffect NOT called (false short-circuits &&)
true || sideEffect(); // sideEffect NOT called (true short-circuits ||)
// ── ?? (nullish coalescing): returns right side ONLY if left is null/undefined ──
console.log(0 ?? "default"); // 0 (0 is NOT null/undefined → keeps 0)
console.log("" ?? "default"); // "" (empty string is NOT null/undefined → keeps "")
console.log(null ?? "default"); // "default" (null → uses default)
console.log(undefined ?? "default"); // "default" (undefined → uses default)
// ── ?? vs ||: the critical difference ──
let count = 0;
count || 10; // 10 — || treats 0 as falsy → uses default (probably a bug!)
count ?? 10; // 0 — ?? only triggers on null/undefined → keeps 0
let name = "";
name || "Anonymous"; // "Anonymous" — || treats "" as falsy
name ?? "Anonymous"; // "" — ?? keeps empty string
// ── ⚠️ Can't mix ?? with || or && without parentheses ──
// 0 || 1 ?? 2; // SyntaxError: mixing ?? with || requires parentheses
(0 || 1) ?? 2; // 1 — explicit grouping
Optional Chaining and Nullish Coalescing in Production
javascript
// ── Optional chaining (?.): short-circuits to undefined if any part is null/undefined ──
const user = { profile: { name: "Alice" } };
// Without ?. (manual null checking — verbose):
const name = user && user.profile && user.profile.name; // "Alice"
const missing = user && user.settings && user.settings.theme; // undefined
// With ?. (clean — short-circuits to undefined):
const name2 = user?.profile?.name; // "Alice"
const missing2 = user?.settings?.theme; // undefined (short-circuits, no error)
// ── Optional method call: ?.() ──
const obj = { greet() { return "hello"; } };
console.log(obj?.greet?.()); // "hello" — calls greet if it exists
console.log(obj?.missing?.()); // undefined — no error (method doesn't exist)
// ── Optional computed property: ?.[] ──
const key = "name";
console.log(user?.profile?.[key]); // "Alice"
console.log(user?.missing?.[key]); // undefined
// ── ⚠️ Optional chaining silently returns undefined (might mask bugs) ──
const data = fetchFromAPI(); // suppose this returns null on error
const value = data?.items?.[0]?.name; // undefined (no error, but was it supposed to?)
// If data is null due to a bug, you get undefined instead of an error — harder to debug.
// Use ?? to provide a fallback or throw:
const value2 = data?.items?.[0]?.name ?? throw new Error("no data");
// ── Combining ?. with ?? for safe deep access with defaults ──
const config = {
server: { port: 3000 }
};
const port = config?.server?.port ?? 8080; // 3000 (exists)
const host = config?.server?.host ?? "localhost"; // "localhost" (undefined → default)
Anti-Pattern: Optional Chaining Hiding Bugs
javascript
// ❌ NAIVE — optional chaining masks a real API error
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
// If the server returns a 500 error, response.json() might return { error: "..." }
// or the response might not have the expected shape
return response?.json?.()?.data?.user?.name; // undefined — no error, but why?
// Was the user not found? Was the API down? Was the response malformed?
// You can't tell — optional chaining ate all the errors.
}
// ✅ CORRECT — check explicitly, throw on unexpected shapes
async function getUserSafe(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const body = await response.json();
if (!body?.data?.user) {
throw new Error("unexpected response shape"); // fail loudly, not silently
}
return body.data.user.name;
}
// ── Use ?. for genuinely optional data (not for error handling) ──
// ✅ Good: user.profile is genuinely optional (not all users have profiles)
const bio = user?.profile?.bio ?? "No bio available";
// ❌ Bad: response.body is NOT optional (API contract says it must be there)
// const data = response?.body?.data; // if body is missing, that's a BUG — let it throw
💡 Tips & Tricks
javascript
// ── `||` for defaults (pre-??), `??` for nullish defaults (modern) ──
const port = config.port ?? 3000; // 3000 only if null/undefined (keeps 0)
const mode = config.mode || "prod"; // "prod" if any falsy (0, "", null, undefined, false)
// ── `&&` for conditional execution (one-liner guard) ──
isDev && initDevtools(); // only run if isDev is truthy
// Equivalent: if (isDev) initDevtools();
// ── Double negate for boolean coercion: !! ──
const isTruthy = !!value; // true if value is truthy, false if falsy
!!0; // false
!!"x"; // true
!![]; // true (empty array is truthy)
!!{}; // true (empty object is truthy)
// ── Comma operator: evaluate multiple expressions, return last ──
let result = (doSideEffect(), computeValue()); // doSideEffect runs, result = computeValue()
// Useful in for-loops: for (let i = 0, j = arr.length - 1; i < j; i++, j--) {}
// ── Ternary chaining (right-associative) ──
const status = code === 200 ? "OK"
: code >= 400 ? "Error"
: code >= 300 ? "Redirect"
: "Unknown";
// Right-associative: a ? b : (c ? d : (e ? f : g)) — evaluated right-to-left
⚠️ Edge Cases & Gotchas
javascript
// ── `0` and `-0` are equal with ===, different with Object.is ──
0 === -0; // true
Object.is(0, -0); // false (only way to distinguish)
1 / 0; // Infinity
1 / -0; // -Infinity (negative zero matters for division)
// ── `NaN` propagates silently through arithmetic ──
NaN + 1; // NaN
NaN * 0; // NaN (not 0! — NaN poisons everything)
NaN === NaN; // false
// ── `++x` vs `x++` (pre-increment vs post-increment) ──
let a = 5;
let b = a++; // b = 5 (post: returns OLD value, THEN increments a to 6)
let c = ++a; // c = 7 (pre: increments FIRST, then returns new value)
// ── `+` operator: addition for numbers, concatenation for strings ──
1 + 2; // 3 (number addition)
"1" + 2; // "12" (string concatenation — if ANY operand is string, concatenate)
1 + 2 + "3"; // "33" — (1+2)=3, then 3+"3"="33" (left-to-right)
"1" + 2 + 3; // "123" — "1"+2="12", then "12"+3="123" (string propagates)
// ── Unary `+` for string-to-number coercion ──
+"42"; // 42 (number)
+""; // 0 (empty string → 0)
+" "; // 0 (whitespace → 0)
+"abc"; // NaN (non-numeric → NaN)
+true; // 1
+null; // 0 (not NaN!)
+undefined; // NaN
+[]; // 0 (array → "" → 0)
+[1]; // 1 (array → "1" → 1)
+[1,2]; // NaN (array → "1,2" → NaN)
🧠 Quick Quiz
What does this output?
javascript
console.log(1 + null);
console.log("1" + null);
console.log(null + null);
console.log([] + []);
console.log([] + {});
Answer
1 // 1 + null → ToNumber(null)=0 → 1+0 = 1
"1null" // "1" + null → String(null)="null" → "1"+"null" = "1null"
0 // null + null → 0+0 = 0 (both coerced to 0)
"" // [] + [] → [].toString()="" → ""+"" = ""
"[object Object]" // [] + {} → ""+"[object Object]" = "[object Object]"
Key rules:
+with a string concatenates (string propagates left-to-right)+with numbers adds (null/undefined/boolean coerced to number)[]→""(empty array toString is empty string){}→"[object Object]"(object toString is[object Object])