Control Flow
Conditionals, Loops, break, continue, labels, Iteration method comparison 🎯, Recursion
Conditionals#
if / else-if — top-down, first match wins#
An if…else if chain is tested top to bottom and stops at the first truthy branch — the rest are never checked. Order matters: put the most specific / most likely conditions first.
function grade(score) {
if (score >= 90) return "A"; // checked first
else if (score >= 80) return "B";
else if (score >= 70) return "C";
else return "F";
}
grade(95); // "A" — stops at the first match, never evaluates the rest
⚠️ Reverse the order (put >= 70 first) and everything ≥ 70 matches that first branch — a classic ordering bug. In an if/else chain, exactly one branch runs.
switch — jumps to the match, then FALLS THROUGH ⚠️ 🎯#
switch compares with strict ===, jumps to the matching case, and then keeps running every case below it until it hits a break (or the end). Missing break is the #1 switch bug.
const fruit = "apple";
switch (fruit) {
case "apple": console.log("Apple");
case "banana": console.log("Banana");
case "orange": console.log("Orange");
default: console.log("Unknown");
}
There is no break anywhere, so execution starts at the matched case and pours through everything after it:
fruit |
Enters at | Output (falls through to the end) |
|---|---|---|
"apple" |
case "apple" |
Apple → Banana → Orange → Unknown |
"banana" |
case "banana" |
Banana → Orange → Unknown |
flowchart TD
M["fruit === 'apple' ✓ — enter here"] --> A["log 'Apple'"]
A -->|no break ↓| B["log 'Banana'"]
B -->|no break ↓| C["log 'Orange'"]
C -->|no break ↓| D["log 'Unknown' (default)"]
Compare the two models: an if/else chain runs exactly one branch; a switch without break runs the match and everything below it.
The fix — add break so only the matched case runs:
switch (fruit) {
case "apple": console.log("Apple"); break;
case "banana": console.log("Banana"); break;
case "orange": console.log("Orange"); break;
default: console.log("Unknown");
}
// fruit = "apple" → "Apple" (only)
// fruit = "banana" → "Banana" (only)
🟢 Intentional fall-through is occasionally useful — stack cases with an empty body to share code:
switch (fruit) {
case "apple":
case "pear": // apple & pear share this body
console.log("pome"); break;
default:
console.log("other");
}
For pure value → value mapping, an object lookup is cleaner and has no fall-through footguns:
const label = { apple: "pome", pear: "pome" }[fruit] ?? "other";
Loops#
for (let i = 0; i < n; i++) { }
while (cond) { }
do { } while (cond); // runs body at least once
for (const x of iterable) { } // 🟢 values (arrays, strings, Maps, Sets, generators)
for (const k in obj) { } // ⚠️ keys, INCLUDING inherited enumerable ones
⚠️ for...in traps: iterates keys as strings, includes inherited properties, and order isn't guaranteed for integer-like keys the way you'd expect. Use it for plain objects only, guard with Object.hasOwn(obj, k), or better use Object.keys/entries. Never use for...in to iterate arrays.
break, continue, labels#
outer:
for (const row of grid) {
for (const cell of row) {
if (cell === target) break outer; // break both loops
if (cell < 0) continue outer;
}
}
🟡 Labels are legitimate for breaking nested loops but often signal that logic should move into a function that can return.
Iteration method comparison 🎯#
| Construct | Iterates | Can break? |
Returns | Async-friendly | Notes |
|---|---|---|---|---|---|
for |
anything | yes | — | with await inside |
most control, fastest |
for...of |
iterables | yes | — | ✅ (for await...of) |
clean, supports break/continue |
for...in |
enumerable keys | yes | — | — | objects only; includes inherited |
forEach |
array | no ⚠️ | undefined | ❌ ignores await |
can't break; skips holes |
map |
array | no | new array | ❌ | transform; don't use for side-effects only |
filter |
array | no | subset array | ❌ | keep where callback truthy |
reduce |
array | no | single value | ❌ | fold; powerful but can hurt readability |
⚠️ You cannot break out of forEach, and await inside forEach does not pause the loop. For sequential async, use for...of with await.
// ❌ doesn't await
items.forEach(async (i) => { await save(i); });
// ✅ sequential
for (const i of items) { await save(i); }
// ✅ parallel
await Promise.all(items.map(i => save(i)));
Recursion#
ELI12. A function that calls itself, shrinking the problem each time until a "base case" stops it.
function factorial(n) {
if (n <= 1) return 1; // base case — always required
return n * factorial(n - 1);
}
⚠️ No base case → infinite recursion → RangeError: Maximum call stack size exceeded. JS engines generally do not optimize tail calls (spec'd but unimplemented in V8), so deep recursion can overflow — convert to iteration or an explicit stack for large inputs.
Interview Q&A#
Q1. Predict the output of a switch with no break for fruit = "apple" vs "banana". Why?
"apple" prints Apple → Banana → Orange → Unknown; "banana" prints Banana → Orange → Unknown. Without break, execution jumps to the matched case and falls through every case below it (including default) until it hits a break or the end.
Q2. How does switch fall-through differ from an if/else-if chain?
An if/else-if chain runs exactly one branch — the first truthy one. A switch without break runs the matched case and everything below it, so branches aren't mutually exclusive unless you add break.
Q3. Why can't you break out of forEach? What do you use instead?
forEach invokes a callback per element; break is a loop keyword with no loop to target, and return only exits the current callback. Use for...of (supports break/continue), or some/every for early exit.
Q4. for...in vs for...of?
for...in iterates enumerable keys as strings, including inherited ones — objects only, guard with Object.hasOwn. for...of iterates values of any iterable (arrays, strings, Maps, Sets). Never use for...in on arrays.
Q5. What happens with await inside forEach? Fix it two ways.
forEach ignores the returned promise, so it does not wait — all iterations fire without pausing. Fix sequentially with for...of + await, or in parallel with Promise.all(items.map(...)).
for (const i of items) { await save(i); } // sequential
await Promise.all(items.map(i => save(i))); // parallel