JavaScript Control Flow
- Description: Conditionals (
if,switch, ternary), loops (for,while,for…of,for…in), labels,break/continue, and modern iteration helpers. - My Notion Note ID: K2A-F4-3
- Created: 2018-03-23
- Updated: 2026-05-17
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. Conditionals
- 2. The Switch Statement
- 3. Loops
- 4. Iterating Collections
- 5. Break, Continue, Labels
- 6. Array Iteration Methods
- 7. Iterators and Generators
- 8. Throwing and Catching
- 9. References
1. Conditionals
1.1 if / else
if (cond) {
// ...
} else if (other) {
// ...
} else {
// ...
}
Braces are optional for single statements but always preferred — saves the "dangling else" bug.
if (cond) doThing(); // works, but vulnerable to later edits
if (cond) { doThing(); } // safe
The condition gets coerced to boolean — see falsy values in javascript-types-and-operators.
1.2 Ternary
const label = isReady ? 'Go' : 'Wait';
// Nested — readable when each branch fits one line
const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : 'C';
Inline-only — use if/else when branches have side effects.
1.3 Guard Clauses
Early returns flatten code and reduce nesting:
function process(user) {
if (!user) return;
if (!user.active) return;
if (user.balance < 0) throw new Error('overdrawn');
// happy path
doThings(user);
}
Easier to read than nested if blocks.
2. The Switch Statement
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
case 'RESET':
case 'CLEAR': // fallthrough — multiple cases share body
return 0;
default:
return state;
}
Comparison is strict equality (===). No type coercion.
Gotchas:
- Missing
break(orreturn) causes fallthrough — sometimes wanted, often a bug. ESLint catches accidental fallthrough. caseclauses share one scope. Declarelet/constinside{ }to scope per case:
case 'EDIT': {
const { id } = action.payload; // scoped to this block
return updateById(state, id);
}
defaultdoesn't have to be last, but conventionally is.
When you'd reach for switch but you really want a lookup:
const handlers = {
INCREMENT: (state) => state + 1,
DECREMENT: (state) => state - 1,
RESET: () => 0,
};
const handler = handlers[action.type];
return handler ? handler(state) : state;
3. Loops
3.1 for
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
Three semicolon-separated parts: init, condition, update. Any part can be empty: for (;;) is an infinite loop.
3.2 while / do…while
while (cond) {
// runs zero or more times
}
do {
// runs once, then while cond
} while (cond);
do…while rare — most loops want condition-first. Use when you genuinely need at-least-once semantics (read until end-of-stream, etc.).
3.3 for…of (iterables)
const arr = [1, 2, 3];
for (const x of arr) {
console.log(x);
}
for (const ch of 'hello') {
console.log(ch);
}
for (const [key, value] of map) {
// Map yields [k, v] pairs
}
for (const [i, v] of arr.entries()) {
// index + value
}
Works on anything iterable: arrays, strings, Map, Set, NodeList, arguments, generators, anything with Symbol.iterator. Use this for almost all "iterate values" loops.
3.4 for…in (object keys — careful)
const obj = { a: 1, b: 2 };
for (const key in obj) {
console.log(key, obj[key]);
}
- Iterates enumerable string keys, including inherited ones from the prototype.
- Order is roughly insertion order for string keys, integer-like keys first.
- Do not use on arrays — picks up custom properties and may return indices as strings (
'0','1'). - Prefer
Object.keys(obj),Object.entries(obj), or afor…ofover them.
for (const [key, value] of Object.entries(obj)) {
// ...
}
3.5 for await…of
Iterate async iterables (streams, paged APIs):
for await (const chunk of response.body) {
process(chunk);
}
Requires async context (async function or top-level await in modules).
4. Iterating Collections
| Want | Recommended |
|---|---|
| Each value in an array | for (const v of arr) or arr.forEach(v => …) |
| Index + value | for (const [i, v] of arr.entries()) or classic for |
| Each key/value in an object | for (const [k, v] of Object.entries(obj)) |
| Each char in a string | for (const ch of str) — handles surrogate pairs |
| Map / Set entries | for (const x of collection) |
| Async stream | for await (const x of stream) |
forEach vs for…of:
for…ofsupportsbreak,continue,return,await.forEachdoesn't — you can't break out, andasynccallbacks don't wait.forEachis fine for fire-and-forget callbacks;for…offor everything else.
5. Break, Continue, Labels
for (let i = 0; i < 10; i++) {
if (i === 5) break; // exit loop entirely
if (i % 2 === 0) continue; // skip rest of this iteration
console.log(i); // 1, 3
}
Labels name an outer loop so an inner break / continue can target it:
outer: for (let i = 0; i < grid.length; i++) {
for (let j = 0; j < grid[i].length; j++) {
if (grid[i][j] === target) {
console.log('found at', i, j);
break outer; // exits both loops
}
}
}
Labels are rare in idiomatic code — refactoring to a function and return-ing is usually cleaner.
6. Array Iteration Methods
Higher-order functions; concise and chainable.
| Method | Returns | Use when |
|---|---|---|
map(fn) |
new array of fn(x) |
Transform each element. |
filter(fn) |
new array of elements where fn(x) is truthy |
Pick a subset. |
reduce(fn, initial) |
accumulator | Fold into a single value. |
forEach(fn) |
undefined |
Side effects, no break support. |
find(fn) |
first matching element or undefined |
Look up one. |
findIndex(fn) |
index or -1 |
Position of first match. |
findLast(fn) / findLastIndex(fn) |
last match | ES2023. |
some(fn) |
boolean — any match? | Early exits at first match. |
every(fn) |
boolean — all match? | Early exits at first non-match. |
includes(value) |
boolean | Strict-equality membership. |
indexOf(value) |
index or -1 |
Strict equality. |
flat(depth) |
flattened array | Unnest nested arrays. |
flatMap(fn) |
flattened map results | One pass of map + flat(1). |
at(i) |
element at index (negative OK) | arr.at(-1) for last. |
toSorted / toReversed / toSpliced |
new array (non-mutating) | ES2023; replaces in-place sort/reverse. |
const sum = nums.reduce((acc, n) => acc + n, 0);
const adults = people.filter(p => p.age >= 18).map(p => p.name);
const totalAge = people.reduce((acc, p) => acc + p.age, 0);
const averageAge = totalAge / people.length;
In-place vs returning new copy:
| Mutating | Non-mutating |
|---|---|
sort, reverse, splice, push, pop, shift, unshift, copyWithin, fill |
toSorted, toReversed, toSpliced, slice, concat, with, flat, flatMap, map, filter, … |
Prefer non-mutating in functional / Redux-style code.
7. Iterators and Generators
7.1 The Iterator Protocol
An iterator implements:
{
next(): { value: any, done: boolean }
}
An iterable has Symbol.iterator returning an iterator. Arrays, strings, Maps, Sets, generators all implement it. for…of, spread, destructuring all consume it.
Manually:
const it = arr[Symbol.iterator]();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
// ...
it.next(); // { value: undefined, done: true }
7.2 Generators
function* and yield build lazy iterators:
function* range(start, end, step = 1) {
for (let i = start; i < end; i += step) yield i;
}
for (const n of range(0, 10, 2)) {
console.log(n); // 0, 2, 4, 6, 8
}
[...range(1, 4)]; // [1, 2, 3]
Each yield pauses execution; next() resumes. Useful for:
- Infinite sequences without allocating arrays.
- Streaming computation.
- Building custom iterables for
for…of.
Async generators (async function* + yield) + for await…of for streams:
async function* lines(url) {
const resp = await fetch(url);
for await (const chunk of resp.body) {
yield* chunk.toString().split('\n');
}
}
7.3 Iterator Helpers (ES2025)
map/filter/take/drop directly on iterators — lazy, no intermediate arrays:
const evens = naturalNumbers()
.filter(n => n % 2 === 0)
.take(5)
.toArray(); // [0, 2, 4, 6, 8]
Solid Chromium/Firefox support since 2024; Safari 2025.
8. Throwing and Catching
try {
doRisky();
} catch (err) {
console.error(err.message);
} finally {
cleanup();
}
catchbinding is optional since ES2019:try { … } catch { … }.finallyruns on success, on caught exception, and when thetry/catchreturns. Thefinallyblock can override the return value if it returns.- Throw anything, but throw
Errorinstances (or subclasses) — they carry a stack trace.
throw new Error('Bad input');
throw new TypeError(`expected number, got ${typeof x}`);
// Custom error classes — pattern-match in catch
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
try {
validate(input);
} catch (err) {
if (err instanceof ValidationError) {
// handle
} else {
throw err; // re-throw what you don't handle
}
}
Error.cause (ES2022) chains causes:
try {
load();
} catch (err) {
throw new Error('Failed to initialise', { cause: err });
}
Async errors flow through await:
try {
const data = await fetchData();
} catch (err) {
// catches both sync errors before await and rejected promise after
}
9. References
- MDN Statements and declarations — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements
- MDN Iteration protocols — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
- MDN
Arraymethods — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array - MDN Generators — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*
- See also: javascript-async-and-event-loop for async iteration in depth.