Control Flow
At an advanced level, control flow in JavaScript is less about if and for syntax and more about the gotchas: implicit coercion in conditions, switch fallthrough, block scoping in loops, and the closure traps they create.
Prerequisites assumed: you know values and types, coercion rules, and function scoping from earlier chapters.
Truthiness and coercion in conditions
Any value in a condition is coerced to boolean. The falsy set is short: false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy — including "0", [], {}, and new Boolean(false).
if ([]) console.log('truthy'); // runs
if ('0') console.log('truthy'); // runs
if (new Boolean(false)) console.log('runs'); // runsDecision criterion: use explicit comparisons (count !== 0, arr.length > 0) at boundaries where a falsy-but-valid value would silently diverge. Reserve truthy checks for idiomatic guards like if (user?.name).
switch: strict comparison and fallthrough
switch compares with ===, never coerces, and falls through without break.
function score(n) {
switch (n) {
case 1:
case 2:
return 'low';
case 3:
return 'high';
default:
return 'unknown';
}
}
score('1'); // 'unknown' — strict comparisonIntentional fallthrough (grouping cases) is fine; accidental fallthrough is a classic bug. Prefer return or break in every non-grouping case, and lint for it.
Block scoping in loops
let creates a fresh binding per iteration; var hoists a single function-scoped binding. This is the difference between a working loop of closures and three closures sharing one variable:
const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map((f) => f())); // [3, 3, 3]
const fns2 = [];
for (let j = 0; j < 3; j++) fns2.push(() => j);
console.log(fns2.map((f) => f())); // [0, 1, 2]The same trap appears with for...of using var. Modern code should essentially never declare loop variables with var.
Labels, breaks, and early exit
Labeled statements let you break out of nested loops — often cleaner than flag variables:
outer:
for (const row of matrix) {
for (const cell of row) {
if (cell < 0) break outer;
}
}For conditional expressions, remember evaluation order: && short-circuits left-to-right and returns the left operand when falsy; || returns the first truthy operand; ?? only skips null/undefined — the right choice for defaults when 0 or "" are valid values.
const port = config.port ?? 3000; // keeps 0, rejects only null/undefinedControl flow via exceptions
try/catch is control flow in JavaScript, not just error plumbing. throw unwinds the stack synchronously — but never across an async boundary, which is why rejections and try/catch around await are covered later in the chapters on Promises and async/await.
Choosing the right construct
| Situation | Construct | ||
|---|---|---|---|
| Value-dependent branching with many cases | `switch` with strict comparison | ||
| Default with valid falsy values | `??`, not `\ | \ | ` |
| Escaping nested loops | labeled `break` | ||
| Per-iteration closure state | `let`, never `var` | ||
| Invalid state that should halt | `throw` |
Exercise
In the first loop above, why do all three closures report 3?
Show answer
var i is one function-scoped binding shared by all iterations. The loop finishes with i === 3, and every closure reads that same binding. With let, each iteration gets its own copy of i, captured at that step.
Exercise
config.retries || 3 breaks when retries is 0. Rewrite it.
Show answer
config.retries ?? 3 — nullish coalescing substitutes the default only for null and undefined, so a legitimate 0 is preserved.
Spotted a mistake, or code that does not work?