04 — Control Flow Internals: Truthiness, Switch Fall-Through & Loop Optimizations
Truthiness: The ToBoolean Coercion
javascript
// ── 8 falsy values (everything else is truthy) ──
// false, 0, -0, 0n (BigInt zero), "", null, undefined, NaN
// ── Truthy values that surprise developers from other languages ──
if ([]) console.log("empty array is truthy"); // prints! (Python: falsy)
if ({}) console.log("empty object is truthy"); // prints! (Python: falsy)
if ("0") console.log("string '0' is truthy"); // prints! (non-empty string)
if ("false") console.log("string 'false' is truthy"); // prints! (non-empty string)
if (Infinity) console.log("Infinity is truthy"); // prints!
if (-1) console.log("negative numbers are truthy"); // prints! (only 0/-0 are falsy)
// ── Truthiness check via !! (double negation) ──
const isTruthy = (val) => !!val;
console.log(isTruthy(0)); // false
console.log(isTruthy("")); // false
console.log(isTruthy([])); // true (empty array is truthy!)
console.log(isTruthy(null)); // false
// ── Anti-pattern: checking length on a possibly-null array ──
// ❌ if (arr && arr.length > 0) — works but verbose
// ✅ if (arr?.length > 0) — optional chaining + length truthiness
// ✅ if (arr?.length) — even cleaner (length 0 is falsy, >0 is truthy)
switch: Strict Equality and Fall-Through
javascript
// ── switch uses STRICT equality (===) — no coercion ──
switch ("1") {
case 1: console.log("number 1"); break; // NOT matched (string !== number)
case "1": console.log("string '1'"); break; // matched (===)
}
// Output: string '1'
// ── Fall-through: without `break`, execution continues to the next case ──
switch (2) {
case 1: console.log("one");
// falls through (no break)
case 2: console.log("two");
// falls through (no break)
case 3: console.log("three");
break; // stops here
case 4: console.log("four"); // skipped
}
// Output: two, three
// ── Intentional fall-through (grouping cases) ──
function getDayType(day) {
switch (day) {
case "Saturday":
case "Sunday": // both fall through to the same body
return "weekend";
case "Monday":
case "Tuesday":
case "Wednesday":
case "Thursday":
case "Friday":
return "weekday";
default:
return "invalid";
}
}
// This is the ONE acceptable use of fall-through (empty case bodies grouped together)
// ── Anti-pattern: accidental fall-through (forgetting break) ──
// ❌ DANGEROUS — missing break causes unintended fall-through
function getStatus(code) {
switch (code) {
case 200: return "OK"; // has return — no fall-through (return exits)
case 404: console.log("not found"); // no break, no return — FALLS THROUGH!
case 500: return "error"; // 404 falls through to here → returns "error"!
}
}
getStatus(404); // "error" (logs "not found", then falls through to return "error")
Anti-Pattern: switch vs Object Lookup
javascript
// ❌ NAIVE — switch is verbose, fall-through-prone, hard to maintain
function getHttpStatus(code) {
switch (code) {
case 200: return "OK";
case 201: return "Created";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
case 500: return "Internal Server Error";
default: return "Unknown";
}
}
// ✅ CORRECT — object lookup is cleaner, no fall-through risk, easy to extend
const HTTP_STATUS = {
200: "OK",
201: "Created",
400: "Bad Request",
401: "Unauthorized",
404: "Not Found",
500: "Internal Server Error",
};
const getHttpStatus2 = (code) => HTTP_STATUS[code] ?? "Unknown";
// Object lookup: O(1) property access vs switch's sequential comparison
// Also: easy to serialize, iterate, or generate from data
// ── Object lookup with function values (dispatch table / strategy pattern) ──
const handlers = {
create: (data) => api.post("/items", data),
update: (id, data) => api.put(`/items/${id}`, data),
delete: (id) => api.delete(`/items/${id}`),
default: () => { throw new Error("unknown action"); },
};
function dispatch(action, ...args) {
const handler = handlers[action] ?? handlers.default;
return handler(...args);
}
// dispatch("create", { name: "Alice" }) → calls handlers.create(...)
Loops: Engine Optimization and Iteration Patterns
javascript
// ── for: classic C-style loop (fastest for arrays) ──
const arr = [1, 2, 3, 4, 5];
for (let i = 0, len = arr.length; i < len; i++) {
console.log(arr[i]);
}
// ⚠️ Cache arr.length in the init (len) — avoids re-reading .length each iteration
// (Modern engines optimize this, but the pattern is still a good habit for hot loops)
// ── for...of: iterates ITERABLES (arrays, strings, Maps, Sets, generators) ──
for (const item of arr) { console.log(item); }
for (const [index, value] of arr.entries()) { console.log(index, value); }
for (const char of "hello") { console.log(char); } // h e l l o
for (const [key, val] of new Map([["a", 1], ["b", 2]])) { console.log(key, val); }
// ── for...in: iterates ENUMERABLE STRING KEYS (objects, including inherited) ──
const obj = { a: 1, b: 2 };
for (const key in obj) { console.log(key, obj[key]); } // "a 1" "b 2"
// ⚠️ for...in iterates PROTOTYPE CHAIN too:
const child = Object.create({ inherited: 3 });
child.own = 4;
for (const key in child) {
console.log(key);
// "own" (own property)
// "inherited" (from prototype!) — usually NOT wanted
}
// ✅ Filter with hasOwnProperty:
for (const key in child) {
if (Object.hasOwn(child, key)) { // only own properties
console.log(key);
}
}
// ⚠️ for...in on arrays: iterates INDICES (strings), not values, and includes prototype additions
for (const i in [10, 20, 30]) { console.log(i, typeof i); } // "0 string" "1 string" "2 string"
// Never use for...in on arrays — use for, for...of, or forEach
// ── while / do...while ──
let i = 0;
while (i < 3) { console.log(i++); } // 0 1 2 (check before body)
let j = 0;
do { console.log(j++); } while (j < 3); // 0 1 2 (check after body — runs at least once)
// ── Labeled break/continue (for nested loops) ──
outer: for (let i = 0; i < 3; i++) {
inner: for (let j = 0; j < 3; j++) {
if (i === 1 && j === 1) break outer; // break out of BOTH loops
console.log(i, j);
}
}
// (0,0) (0,1) (0,2) (1,0) — breaks outer when (1,1)
try / catch / finally: The finally Trap
javascript
// ── finally ALWAYS runs — even after return, break, continue, or throw ──
function example() {
try {
return "try"; // value is captured, but NOT returned yet
} catch (e) {
return "catch";
} finally {
console.log("finally runs before return"); // runs BEFORE return completes
// if finally has its own return, it OVERRIDES try's return:
// return "finally"; // ← uncomment to override "try" return
}
}
console.log(example()); // logs "finally runs before return", then returns "try"
// ── finally overrides try's return ──
function override() {
try {
return "try";
} finally {
return "finally"; // overrides try's return value!
}
}
console.log(override()); // "finally" (try's "try" is discarded)
// ── finally runs even after throw ──
function withThrow() {
try {
throw new Error("boom");
} catch (e) {
return "caught"; // value captured
} finally {
console.log("cleanup"); // runs before return completes
}
}
console.log(withThrow()); // logs "cleanup", returns "caught"
// ── Anti-pattern: finally that throws swallows the original error ──
function badFinally() {
try {
throw new Error("original error");
} finally {
throw new Error("finally error"); // OVERRIDES the original error!
// The caller sees "finally error", not "original error" — hard to debug
}
}
// badFinally(); // throws "finally error" (original error lost)
// ── Optional catch binding (ES2019) — omit the error variable ──
try {
JSON.parse(invalidJson);
} catch { // no (e) needed if you don't use the error
console.log("invalid JSON, ignoring");
}
// ── try/catch with await (ES2019) ──
async function safeFetch(url) {
try {
const response = await fetch(url);
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
console.log("network error");
}
throw error; // re-throw
} finally {
console.log("cleanup"); // runs after try/catch, even with await
}
}
Production Pattern: Retry with Exponential Backoff
javascript
async function retry(fn, { maxAttempts = 5, baseDelay = 1000, maxDelay = 30000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn(attempt); // pass attempt number to the function
} catch (error) {
attempt++;
if (attempt >= maxAttempts) throw error; // give up
// Exponential backoff with jitter (prevent thundering herd):
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
const jitter = Math.random() * delay * 0.1; // ±10% jitter
console.warn(`attempt ${attempt} failed, retrying in ${Math.round(delay + jitter)}ms`);
await new Promise(r => setTimeout(r, delay + jitter));
}
}
}
// Usage:
// const data = await retry(() => fetch("/api/data").then(r => r.json()), {
// maxAttempts: 3,
// baseDelay: 500,
// });
💡 Tips & Tricks
javascript
// ── `continue` in a for loop skips to the increment ──
for (let i = 0; i < 10; i++) {
if (i % 2 === 0) continue; // skip even numbers
console.log(i); // 1 3 5 7 9
}
// ── `break` in a for...of exits the loop ──
for (const item of items) {
if (item === "stop") break; // exit the loop
console.log(item);
}
// ── Find first match with for...of (no .find() needed) ──
function findFirst(arr, predicate) {
for (const item of arr) {
if (predicate(item)) return item; // return exits the loop
}
return undefined;
}
// ── Early return from a loop (cleaner than a flag variable) ──
function contains(arr, target) {
for (const item of arr) {
if (item === target) return true; // early exit
}
return false;
}
// ── while(true) with break for polling ──
while (true) {
const result = await checkCondition();
if (result.done) break;
await sleep(1000);
}
// ── Object lookup for O(1) dispatch (vs switch's O(n)) ──
const operations = {
add: (a, b) => a + b,
sub: (a, b) => a - b,
mul: (a, b) => a * b,
div: (a, b) => a / b,
};
function calculate(op, a, b) {
if (!(op in operations)) throw new Error(`unknown op: ${op}`);
return operations[op](a, b); // O(1) lookup
}
⚠️ Edge Cases & Gotchas
javascript
// ── `if ([])` is truthy (empty array is an object → truthy) ──
if ([]) console.log("runs"); // prints! ([] is truthy, not falsy)
if ({} && "false") console.log("runs"); // prints! ("false" is a non-empty string → truthy)
// ── `switch` without `break` falls through silently ──
// ESLint rule `no-fallthrough` catches this, but it's a runtime bug if missed.
// ── `for...in` iterates string keys (not values), includes prototype ──
Array.prototype.extra = "oops";
for (const i in [1, 2, 3]) { console.log(i); }
// "0", "1", "2", "extra" — prototype property leaked in!
// Use for...of for arrays (iterates values, not keys, and ignores prototype).
// ── `finally` with return overrides try's return ──
function f() { try { return 1; } finally { return 2; } }
console.log(f()); // 2 (finally's return wins — the try's return value is lost)
// ── `break` in a nested loop only exits the inner loop ──
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (j === 1) break; // only breaks inner loop (outer continues)
}
}
// Use labeled break: `break outerLabel;` to exit multiple levels.
// ── `continue` in a while loop (must update counter BEFORE continue) ──
let i = 0;
while (i < 5) {
i++; // ⚠️ MUST increment BEFORE continue, or infinite loop
if (i === 3) continue; // skip 3
console.log(i); // 1 2 4 5
}
🧠 Quick Quiz
What does this return?
javascript
function test() {
try {
throw "error1";
} catch (e) {
throw "error2";
} finally {
return "finally";
}
}
test();
Answer
"finally"
Even though the catch block throws "error2", the finally block runs regardless — and finally has its own return "finally", which overrides the thrown error. The function returns "finally" without throwing.
If finally didn't have a return, "error2" would propagate to the caller. But finally's return swallows any pending throw from try or catch.
The lesson: finally with return is dangerous — it swallows errors from both try and catch. Never return from finally unless you intentionally want to override all exit paths.