Control Flow
Programs often need to make decisions based on conditions and repeat actions multiple times. Control flow mechanisms let you direct the execution path of your code.
But control flow is more than syntax. It's how you reason about what your program does. Every if statement partitions your program's universe into two worlds. Every loop is a machine that must eventually stop. Understanding control flow deeply means understanding how to write code you can prove is correct.
This lesson teaches you both the mechanics and the mental models that professional programmers use to write reliable software.
Making Decisions with if/else
The if statement runs code only when a condition is true:
const temperature = 75;
if (temperature > 80) {
console.log("It is hot outside!");
}You can provide an alternative using else:
const temperature = 75;
if (temperature > 80) {
console.log("It is hot outside!");
} else {
console.log("It is not too hot.");
}For multiple conditions, use else if:
const temperature = 75;
if (temperature > 80) {
console.log("It is hot outside!");
} else if (temperature > 60) {
console.log("It is pleasant outside.");
} else {
console.log("It is cold outside.");
}Guard Clauses: A Better Pattern
As you write more code, you will find that nested if/else becomes hard to read:
// Hard to read: deeply nested
function processOrder(order) {
if (order) {
if (order.items.length > 0) {
if (order.isPaid) {
// Finally, the main logic
shipOrder(order);
return "shipped";
} else {
return "not paid";
}
} else {
return "no items";
}
} else {
return "no order";
}
}Guard clauses handle one case at a time - check a condition, handle it, move on. This keeps the main logic at the natural indentation level:
// Easy to read: guard clauses
function processOrder(order) {
if (!order) {
return "no order";
}
if (order.items.length === 0) {
return "no items";
}
if (!order.isPaid) {
return "not paid";
}
// Main logic - not nested
shipOrder(order);
return "shipped";
}Why Guard Clauses Work
Guard clauses embody a principle that will serve you throughout your career: make invalid states unreachable.
Consider what the nested version does: it allows your code to proceed deeper and deeper while still in potentially invalid states. The order might be null, but you're already three levels deep. The guard clause version is different - by the time you reach shipOrder(order), you have proven that:
orderexists (not null)order.itemshas at least one itemorder.isPaidis true
This is not just cleaner code - it's a proof embedded in the structure of your program. Each guard clause eliminates one class of invalid states. What remains is the valid path.
- Invalid states are eliminated early - Each guard removes one way the code could fail
- The main logic has preconditions guaranteed - You can reason about it in isolation
- Adding new constraints is additive - New guards don't restructure existing code
- The code structure matches the logical structure - What you see is what executes
Comparison Operators
Conditions use comparison operators to produce boolean values:
10 > 5; // Greater than: true
10 < 5; // Less than: false
10 >= 10; // Greater than or equal: true
10 <= 5; // Less than or equal: false
10 === 10; // Equal (strict): true
10 !== 5; // Not equal: true
// Avoid == and != in JavaScript (they have unexpected behavior)
10 == "10"; // true (unexpected!)
10 === "10"; // false (strict equality is better)Logical Operators
Combine or modify boolean conditions:
// AND - both conditions must be true
true && true; // true
true && false; // false
// OR - at least one condition must be true
true || false; // true
false || false; // false
// NOT - reverses the value
!true; // false
!false; // true
// Example: checking a range
const age = 25;
if (age >= 18 && age <= 65) {
console.log("Person is of working age.");
}Repeating with Loops
Loops let you repeat code multiple times. But loops are also where most bugs hide. Understanding loops deeply - not just their syntax, but why they work - is what separates reliable programmers from those who debug endlessly.
Every correct loop has three properties:
- It makes progress - Each iteration moves closer to termination
- It terminates - It eventually stops
- It maintains invariants - Certain truths hold before and after each iteration
We will return to these properties after learning the syntax.
while Loop
The while loop repeats while a condition is true:
let count = 0;
while (count < 5) {
console.log(count);
count = count + 1; // Do not forget this, or it loops forever!
}
// Prints: 0, 1, 2, 3, 4for Loop
The for loop is ideal when you know how many iterations you need:
// for (initializer; condition; increment)
for (let i = 0; i < 5; i = i + 1) {
console.log(i);
}
// Prints: 0, 1, 2, 3, 4The parts of a for loop:
- Initializer:
let i = 0- runs once before the loop - Condition:
i < 5- checked before each iteration - Increment:
i = i + 1- runs after each iteration
Notice how the for loop makes the three properties explicit:
- Progress:
i = i + 1ensuresigrows each iteration - Termination: Since
igrows and starts at 0, it will eventually reach 5 - Invariant: At the start of each iteration,
iis the number of iterations completed so far
Iterating Over Arrays
You will often loop through arrays:
const fruits = ["apple", "banana", "cherry"];
// Classic for loop
for (let i = 0; i < fruits.length; i = i + 1) {
console.log(fruits[i]);
}
// for...of loop (cleaner when you do not need the index)
for (const fruit of fruits) {
console.log(fruit);
}Reasoning About Loops: Invariants
Here is a secret that most programmers learn too late: you cannot debug your way to correct loops. You must reason your way to them. The tool for this is the loop invariant - a statement that is true before the loop starts and remains true after each iteration.
Consider summing an array:
function sum(numbers) {
let total = 0;
for (let i = 0; i < numbers.length; i = i + 1) {
total = total + numbers[i];
}
return total;
}The invariant here is: after iteration i, total equals the sum of numbers[0] through numbers[i-1].
Let us trace it:
- Before the loop:
total = 0, which is the sum of zero elements (correct) - After iteration 0:
total = numbers[0], the sum of elements 0 through 0 (correct) - After iteration 1:
total = numbers[0] + numbers[1], the sum of elements 0 through 1 (correct) - After the loop:
i = numbers.length, sototalis the sum of all elements (correct)
This is not pedantic - it is how you write loops that work the first time.
Finding the Invariant
When writing a loop, ask yourself: what is true after k iterations? Your answer is the invariant. Design your loop to maintain it.
// Finding the maximum value
function max(numbers) {
let largest = numbers[0];
for (let i = 1; i < numbers.length; i = i + 1) {
if (numbers[i] > largest) {
largest = numbers[i];
}
}
return largest;
}
// Invariant: largest is the maximum of numbers[0] through numbers[i-1]// Counting occurrences
function countOccurrences(items, target) {
let count = 0;
for (let i = 0; i < items.length; i = i + 1) {
if (items[i] === target) {
count = count + 1;
}
}
return count;
}
// Invariant: count is the number of times target appears in items[0] through items[i-1]The Three Questions
Before writing any loop, answer these questions:
- What is the invariant? What is true after each iteration?
- Does the loop make progress? Does each iteration move toward termination?
- Does the loop terminate? Will the condition eventually become false?
If you cannot answer all three, you do not yet understand your loop well enough to write it.
Breaking Out of Loops
Use break to exit a loop early:
const numbers = [1, 3, 5, 7, 8, 9, 11];
// Find first even number and stop
for (const num of numbers) {
if (num % 2 === 0) {
console.log("Found even:", num);
break; // Exit the loop
}
}
// Prints: Found even: 8Use continue to skip the current iteration:
const numbers = [1, 2, 3, 4, 5];
// Print only odd numbers
for (const num of numbers) {
if (num % 2 === 0) {
continue; // Skip even numbers
}
console.log(num);
}
// Prints: 1, 3, 5Early Return in Loops
Guard clauses work in loops too. Instead of:
for (const item of items) {
if (item.isValid) {
if (item.isActive) {
processItem(item);
}
}
}Write:
for (const item of items) {
if (!item.isValid) {
continue;
}
if (!item.isActive) {
continue;
}
processItem(item);
}Switch and Lookup Tables
When checking the same value against multiple options, switch is cleaner than chained if/else:
function getStatusMessage(status) {
switch (status) {
case "pending":
return "Waiting...";
case "success":
return "Done!";
case "error":
return "Something went wrong.";
default:
return "Unknown status.";
}
}In JavaScript, an object lookup table often works better than switch:
const statusMessages = {
pending: "Waiting...",
success: "Done!",
error: "Something went wrong.",
};
function getStatusMessage(status) {
return statusMessages[status] ?? "Unknown status.";
}The lookup table approach is more concise, and the data (messages) is separated from the logic (lookup). When your cases just map values to values, prefer the table.
Control Flow as State Machines
Here is a mental model that will serve you for decades: every program is a state machine.
Consider a traffic light. It has three states (red, yellow, green) and rules for transitioning between them. Your control flow is how you express those transitions.
// A traffic light as a state machine
function nextLightState(currentState) {
switch (currentState) {
case "green":
return "yellow";
case "yellow":
return "red";
case "red":
return "green";
default:
throw new Error(`Invalid state: ${currentState}`);
}
}Notice the default case throws an error. This is intentional - if we receive an invalid state, we want to know immediately. This is another example of making invalid states impossible - if the only way to get a state is through nextLightState, and we start with a valid state, we can never reach an invalid one.
This mental model scales to complex systems:
- A user can be "logged out", "logging in", "logged in", or "logging out"
- An order can be "pending", "paid", "shipped", or "delivered"
- A form can be "empty", "valid", "invalid", or "submitting"
When you think in states and transitions, your control flow writes itself. The if statements and loops are just how you express the machine in code.
Check Your Understanding
What does the === operator do?
What is a guard clause?
What is a loop invariant?
Before writing a loop, which questions should you answer?
Try It Yourself
Practice control flow with a counting exercise:
Summary
You learned the syntax:
if,else if,elsefor conditional execution- Comparison operators:
>,<,>=,<=,===,!== - Logical operators:
&&(AND),||(OR),!(NOT) whileandforloops for repetitionbreakto exit loops,continueto skip iterationsswitchfor multiple cases, and object lookup tables as a cleaner alternative
But more importantly, you learned the mental models:
- Guard clauses eliminate invalid states early, so your main logic only handles valid cases
- Loop invariants let you reason about correctness without tracing every iteration
- The three questions (invariant, progress, termination) tell you if your loop is correct before you run it
- State machine thinking helps you model problems as states and transitions
These are not just techniques - they are how professional programmers think. The syntax you will look up when you forget it. The mental models you will use every day for the rest of your career.
Next, we will explore how to organize code into reusable functions - the building blocks of larger programs.