Control flow is the very first thing that made me feel like I was actually “programming” rather than just writing static instructions. Being able to make decisions with conditions and repeat actions with loops is what turns a flat list of statements into genuine logic. In this article, I’ll walk through every major control flow construct in JavaScript, along with the details about how they behave that took me a while to fully appreciate.
What Is Control Flow?
By default, JavaScript executes statements from top to bottom, one after another. Control flow structures — conditionals and loops — let me change that default order: skipping code, repeating code, or branching down different paths depending on runtime conditions.
Conditional Statements
if, else if, else
const age = 20;
if (age < 13) {
console.log("Child");
} else if (age < 20) {
console.log("Teenager");
} else {
console.log("Adult");
}
// Output: Adult
I evaluate conditions top to bottom — the first true condition’s block runs, and the rest are skipped entirely, even if they would also technically be true.
Truthy and Falsy Values
Every value in JavaScript is either “truthy” or “falsy” when evaluated in a boolean context like an if condition. There are exactly eight falsy values I’ve memorized:
console.log(Boolean(false)); // false
console.log(Boolean(0)); // false
console.log(Boolean(-0)); // false
console.log(Boolean(0n)); // false (BigInt zero)
console.log(Boolean("")); // false
console.log(Boolean(null)); // false
console.log(Boolean(undefined)); // false
console.log(Boolean(NaN)); // false
Everything else is truthy, including "0" (a non-empty string), [] (an empty array), and {} (an empty object) — these surprise beginners constantly.
if ("0") console.log("truthy!"); // runs — non-empty string
if ([]) console.log("truthy!"); // runs — empty array is still an object, and objects are always truthy
if ({}) console.log("truthy!"); // runs — same reasoning
The Ternary Operator
const age = 20;
const status = age >= 18 ? "Adult" : "Minor";
console.log(status); // Adult
I use the ternary operator for simple, single-value conditional assignments — anything more complex than that, I switch back to a regular if/else for readability.
switch Statements
const day = "Tuesday";
switch (day) {
case "Monday":
console.log("Start of the week");
break;
case "Tuesday":
case "Wednesday":
case "Thursday":
console.log("Midweek");
break;
case "Friday":
console.log("Almost weekend");
break;
default:
console.log("Weekend");
}
// Output: Midweek
switch uses strict equality (===) to compare the expression against each case. I always include a break after each case’s logic — forgetting it causes fall-through, where execution continues into the next case regardless of whether it matches, which I sometimes use intentionally (like grouping "Tuesday", "Wednesday", "Thursday" above) but which is a very common source of bugs when accidental.
Logical Operators for Control Flow
const user = null;
const name = user && user.name; // short-circuits to null if user is falsy, avoiding a TypeError
console.log(name); // null
const displayName = user?.name ?? "Guest"; // optional chaining + nullish coalescing
console.log(displayName); // Guest
I use ?. (optional chaining) to safely access nested properties that might not exist, and ?? (nullish coalescing) to provide a fallback specifically when a value is null or undefined — unlike ||, which also falls back on any falsy value like 0 or "".
const count = 0;
console.log(count || 10); // 10 — WRONG if 0 is a valid value!
console.log(count ?? 10); // 0 — correct, since 0 is not null/undefined
Loops
for Loop
for (let i = 0; i < 5; i++) {
console.log(i);
}
// 0, 1, 2, 3, 4
The three parts — initialization, condition, increment — give me full control, which is why I reach for a classic for loop when I need precise control over the iteration variable (like stepping by twos, or iterating backwards).
for (let i = 10; i > 0; i -= 2) {
console.log(i);
}
// 10, 8, 6, 4, 2
while Loop
let count = 0;
while (count < 3) {
console.log(count);
count++;
}
// 0, 1, 2
I use while when the number of iterations isn’t known ahead of time — for example, reading from a stream until it’s exhausted.
do…while Loop
let n = 5;
do {
console.log(n);
n++;
} while (n < 5);
// Logs 5 — the body always runs at least once, even though the condition is already false
The key difference from while is that do...while checks its condition after running the loop body, guaranteeing at least one execution.
for…of (Iterating Values)
const fruits = ["apple", "banana", "cherry"];
for (const fruit of fruits) {
console.log(fruit);
}
for...of works on any iterable — arrays, strings, Map, Set, and more — and gives me the values directly.
for (const char of "hi") console.log(char); // h, i
for…in (Iterating Keys)
const user = { name: "Alice", age: 30 };
for (const key in user) {
console.log(key, user[key]);
}
// name Alice
// age 30
for...in iterates over enumerable property keys, including inherited ones from the prototype chain — this is exactly why I use for...in almost exclusively for plain objects, and for...of (often combined with Object.entries()) for arrays and other iterables, to avoid accidentally picking up inherited properties.
// AVOID using for...in on arrays:
const arr = [10, 20, 30];
for (const index in arr) console.log(index); // "0", "1", "2" — strings, not numbers!
Loop Control: break and continue
for (let i = 0; i < 10; i++) {
if (i === 5) break; // exits the loop entirely
console.log(i);
}
// 0, 1, 2, 3, 4
for (let i = 0; i < 5; i++) {
if (i === 2) continue; // skips just this iteration
console.log(i);
}
// 0, 1, 3, 4
Labeled Statements for Nested Loops
outer: for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (j === 1) continue outer; // continues the OUTER loop, not the inner one
console.log(i, j);
}
}
// 0 0
// 1 0
// 2 0
I rarely use labeled loops, but they’re the cleanest solution when I genuinely need to break or continue an outer loop from inside a nested one.
Iterating with Array Methods vs. Loops
const numbers = [1, 2, 3, 4, 5];
// Loop-based
const doubled = [];
for (const n of numbers) {
doubled.push(n * 2);
}
// Method-based (more declarative)
const doubledMethod = numbers.map((n) => n * 2);
I generally prefer array methods (map, filter, reduce) for transforming data, and reserve explicit loops for cases where I need break/continue, multiple simultaneous outputs, or non-array iteration patterns.
Internal Working: How Conditions Are Evaluated
When JavaScript evaluates a condition in an if statement, it doesn’t require the expression to already be a boolean — it performs an internal ToBoolean coercion, converting the value according to the truthy/falsy rules described earlier. This coercion step is why if ("hello") works without me needing to write if ("hello".length > 0).
For switch statements, the engine uses the Strict Equality Comparison algorithm (the same one === uses) between the switch expression and each case value — this is why switch(1) { case "1": ... } does not match, since 1 === "1" is false.
Practical, Real-World Applications
Form Validation with Multiple Conditions
function validateForm({ email, password }) {
if (!email || !email.includes("@")) {
return "Please enter a valid email";
}
if (!password || password.length < 8) {
return "Password must be at least 8 characters";
}
return null; // no errors
}
Building a Simple State Machine with switch
function nextState(current, action) {
switch (current) {
case "idle":
return action === "start" ? "running" : current;
case "running":
return action === "pause" ? "paused" : action === "stop" ? "idle" : current;
case "paused":
return action === "resume" ? "running" : current;
default:
return current;
}
}
console.log(nextState("idle", "start")); // running
Retrying an Operation with a while Loop
async function fetchWithRetries(url, maxAttempts) {
let attempt = 0;
while (attempt < maxAttempts) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
} catch (error) {
console.warn(`Attempt ${attempt + 1} failed`);
}
attempt++;
}
throw new Error("All attempts failed");
}
Best Practices
- I always use
===/!==instead of==/!=in conditions to avoid unexpected type coercion. - I prefer
??over||when I specifically want to fall back only onnull/undefined, not on other falsy values like0. - I always include
breakinswitchcases unless fall-through is intentional (and I comment when it is). - I choose array methods over manual loops for straightforward data transformations, and reserve loops for cases needing
break/continueor complex iteration logic.
Common Mistakes to Avoid
- Forgetting
breakin aswitchstatement, causing unintended fall-through. - Using
for...inon arrays, which iterates string-typed indices and can include inherited enumerable properties. - Using
||for defaults when0or""are valid values, silently overriding legitimate falsy values. - Writing infinite loops accidentally by forgetting to update the loop’s condition variable.
Debugging Tips
- I use
console.logright before a conditional to inspect the actual value and type being evaluated. - For infinite loop bugs, I check that the loop’s condition variable is actually being updated inside the loop body.
- I use browser DevTools breakpoints inside loop bodies to step through iterations one at a time when a loop behaves unexpectedly.
FAQs
Q: What’s the difference between == and ===? A: == performs type coercion before comparing (e.g., "5" == 5 is true); === compares both value and type without coercion ("5" === 5 is false). I use === almost exclusively.
Q: Does a switch statement need a default case? A: No, it’s optional, but I include one anyway to handle unexpected values explicitly rather than silently doing nothing.
Q: Can I use break inside a forEach() callback? A: No — break/continue don’t work inside callback functions like forEach. Use a regular for or for...of loop if you need to exit early.
Q: Is for...of faster than .forEach()? A: Performance is comparable in most engines for typical use cases; I choose based on whether I need break/continue (favoring for...of) or a concise callback style (favoring forEach).
Summary and Key Takeaways
if/else, ternary expressions, andswitchall provide different ways to branch code based on conditions.- Understand JavaScript’s truthy/falsy coercion rules — especially the eight falsy values — to avoid unexpected conditional behavior.
for,while,do...while,for...of, andfor...ineach serve different iteration needs; know which to reach for.for...initerates keys (including inherited ones) and is best reserved for plain objects, not arrays.breakandcontinue(with optional labels) give fine-grained control over loop execution.
Mastering control flow is genuinely foundational — nearly every bug I’ve debugged over the years eventually traces back to a condition or loop not behaving the way I assumed it would, which is exactly why understanding these mechanics deeply pays off constantly.