Control Flow
This chapter assumes you already know JavaScript's operators, expressions, and variable scoping from earlier chapters, and that you are comfortable reading short functions. Here we treat control flow not as a beginner's "if and while" tour but as a precision tool: what the constructs actually evaluate to, where they diverge from直觉 intuition, and how their semantics interact with coercion, exceptions, and the event loop.
Branching: `if`, `else`, and the Conditional Operator
if coerces its condition to a boolean using the rules of ToBoolean: 0, NaN, "", null, undefined, and false are falsy; everything else — including [], {}, and -0's sibling objects — is truthy. Two consequences matter at an advanced level:
if (value)silently discards the distinction between0,NaN,"",null, andundefined. When those mean different failure modes, test explicitly:if (value === undefined).??(nullish coalescing) and||answer different questions.a || bfalls through on any falsya;a ?? bonly onnull/undefined. Mixing them without parentheses is a syntax error, which is the language forcing you to be explicit about intent.
The conditional operator cond ? a : b is an expression, not a statement: it evaluates to a value and can appear anywhere an expression is allowed. if/else cannot. That distinction drives style choices in expression-oriented code:
const label = count === 0 ? "empty" : count > 100 ? "many" : "some";
// Legal with the conditional operator, illegal with if/else:
const plan = allowed && (premium ? buyPremium : buyStandard);Chained ternaries read poorly past two levels. Prefer if/else with early returns, or extract a function.
The switch statement uses strict (===) comparison for its cases and, critically, falls through without break. Intentional fallthrough is legal but should be documented with a comment; many linters require it. switch also has a single shared scope for all clauses, so let inside one case leaks to the others — wrap bodies in braces when declaring.
Loops and Their Exit Semantics
JavaScript offers five loop forms, and their differences are semantic, not just syntactic:
| Construct | Iterates over | Yields | Watch out for |
|---|---|---|---|
| `for` | anything you control | — | closure capture of `var` |
| `for...of` | iterables (arrays, strings, Maps) | values | not usable on plain objects |
| `for...in` | enumerable string keys | keys | inherited and non-index keys |
| `while` / `do...while` | condition | — | `do...while` runs at least once |
for...of and for...in are not interchangeable. for...in walks property names, including inherited enumerable ones, and on arrays returns string keys in unspecified relative order for non-index properties. For arrays, for...of (or array methods) is almost always correct:
const base = { inherited: true };
const arr = [10, 20, 30];
arr.__proto__ = base;
for (const k in arr) console.log(k); // "0" "1" "2" "inherited"
for (const v of arr) console.log(v); // 10 20 30break exits the nearest enclosing loop or switch; continue skips to the next iteration. Labeled statements let break/continue target an outer loop, which avoids flag variables:
outer: for (const row of grid) {
for (const cell of row) {
if (cell === target) break outer;
}
}Note that for...of in an async function does not implicitly await each element — awaiting inside the loop body pauses per iteration, which serializes work. For concurrency, use Promise.all with map instead (covered in the asynchrony chapters).
Expressions That Control Flow
Several "non-statement" constructs participate in control flow and short-circuit evaluation:
&&and||return one of their operands, not a boolean:"a" && "b"is"b". Short-circuiting means the right operand is not evaluated at all — the basis of guards likeif (obj && obj.ready).- Optional chaining
a?.bshort-circuits the whole chain whenais nullish, yieldingundefinedwithout evaluatingb. a ?? bshort-circuits on nullishaonly.
Comma sequencing and assignment-as-expression (a = b = c) are legal but rarely improve clarity at this level.
Exceptions as Control Flow
throw can throw any value, but only Error instances (or subclasses) populate stack and interact well with debugging. try/catch/finally has precise ordering semantics: finally runs whether the block completes, breaks, returns, or throws — and a return inside finally silently replaces any pending return or exception. That is almost always a bug:
function risky() {
try {
throw new Error("real failure");
} finally {
return "ok"; // swallows the error entirely
}
}catch without a binding (catch { ... }) is available when the error value is unneeded. Note that catch catches synchronously thrown errors only; errors from async operations require promise rejection handling (later chapters).
The Diagram: One Path Through a `try/finally` Loop
The key insight: finally is on every exit path. Reasoning about loops with cleanup code means enumerating all four exits — completion, break, continue, and throw.
Choosing Constructs: Decision Criteria
- Guard clauses with early
returnbeat nestedif/elsefor flat, readable logic. for...ofover arrays and iterables; neverfor...inover arrays.??and?.for nullish handling; reserve||for genuine "any falsy default" intent.- Avoid
returninsidefinally; let cleanup code throw only if it must. - Labeled loops only when nested-loop exit is genuinely two levels deep; otherwise extract a function and
return.
function process(items) {
if (!items) return [];
if (items.length === 0) return [];
for (const item of items) {
if (item.invalid) continue;
if (item.fatal) throw new Error(`bad item: ${item.id}`);
}
return items;
}Exercise
What does the following print, and why? function f() { try { return "try"; } finally { console.log("finally"); } } console.log(f());
Show answer
It prints "finally" then "try". The return value is computed first, then the finally block runs before the function actually returns, so "finally" logs before the caller receives "try".
Exercise
Why does for (const k in [1,2,3]) risk more than just returning string keys?
Show answer
Because for...in enumerates inherited enumerable properties too. If Array.prototype or the array's prototype chain gains an enumerable property, its key appears in the loop, breaking logic that assumes only indices.
Spotted a mistake, or code that does not work?