JavaScript Basics: Control Flow, Conditions and Loops

JavaScript Basics: Control Flow, Conditions and Loops

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 on null/undefined, not on other falsy values like 0.
  • I always include break in switch cases 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/continue or complex iteration logic.

Common Mistakes to Avoid

  1. Forgetting break in a switch statement, causing unintended fall-through.
  2. Using for...in on arrays, which iterates string-typed indices and can include inherited enumerable properties.
  3. Using || for defaults when 0 or "" are valid values, silently overriding legitimate falsy values.
  4. Writing infinite loops accidentally by forgetting to update the loop’s condition variable.

Debugging Tips

  • I use console.log right 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, and switch all 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, and for...in each serve different iteration needs; know which to reach for.
  • for...in iterates keys (including inherited ones) and is best reserved for plain objects, not arrays.
  • break and continue (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.

References

Total
0
Shares

Leave a Reply

Previous Post
JavaScript-Basics-Variables-Data-Types-and-Operators

JavaScript Basics: Variables, Data Types, and Operators

Next Post
JavaScript Basics: Functions and Scope

JavaScript Basics: Functions and Scope

Related Posts