🧭 Common Control Flow Patterns
You now know the raw syntax of if, switch, and loops. Experienced developers reach for the same handful of patterns built from that syntax again and again. This lesson names those patterns — guard clauses, lookup tables, state machines, filter-map-reduce, robust error handling, and async/await — so you can recognise them in code and apply them on purpose.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Apply the guard clause pattern to flatten validation-heavy functions
- Replace verbose
switchblocks with object lookup tables where appropriate - Model multi-state workflows with a small state machine
- Chain filter → map → reduce to transform collections declaratively
- Structure error handling with try/catch/finally and custom error types
- Sequence and parallelise async work with async/await and
Promise.all
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a mini order-processor that layers guard clauses, a state machine, and async/await.
In This Lesson
What Is a Control Flow Pattern?
A control flow pattern is a reusable way of arranging conditions, loops, and function calls to solve a recurring problem clearly. Patterns aren't new language features — they're proven shapes that make code more readable, testable, and easier for other developers to recognise.
💡 Analogy — city traffic. A program is a city and control flow is its traffic system. Conditionals are traffic lights (proceed or stop), loops are circular transit routes (revisit the same area), and function calls are detours through a neighbourhood before rejoining the main road. Patterns are the standard intersection designs the city reuses everywhere.
Learning them pays off in three ways: you write clearer code, you read others' code faster, and you debug with more insight because you recognise the intended shape.
The Guard Clause Pattern
A guard clause checks a disqualifying condition at the top of a function and returns early. By handling every failure case up front, the main logic stays flat and unindented — no pyramid of nested if blocks drifting off the right edge of the screen.
// Deeply nested — hard to scan
function processUser(user) {
if (user) {
if (user.name) {
if (user.age >= 18) {
return { displayName: user.name.toUpperCase(), isAdult: true };
} else return "User is under 18";
} else return "User has no name";
} else return "No user provided";
}
// Guard clauses — flat and explicit
function processUserBetter(user) {
if (!user) return "No user provided";
if (!user.name) return "User has no name";
if (user.age < 18) return "User is under 18";
return { displayName: user.name.toUpperCase(), isAdult: true };
}
✅ Why it helps
- Edge cases are visible at the very top, where reviewers look first.
- The "happy path" reads top-to-bottom with zero nesting.
- Adding a new rule is a one-line insertion, not a re-indent of the whole function.
Switch vs. Lookup Table
A switch that maps one value to another value is often expressed more compactly as an object lookup. The object becomes a small data structure you can define once, extend, or even load from configuration.
// Switch version
function shippingCost(country) {
switch (country.toLowerCase()) {
case "usa": return 5.99;
case "canada": return 10.99;
case "australia": return 24.99;
default: return 15.99;
}
}
// Lookup-table version
const RATES = { usa: 5.99, canada: 10.99, australia: 24.99 };
function shippingCostBetter(country) {
return RATES[country.toLowerCase()] ?? 15.99; // ?? handles "no match"
}
📖 When to keep the switch
Use a lookup table when each case just returns a value. Keep the switch when cases run several statements, share code through deliberate fall-through, or need break/early-exit logic. The ?? (nullish coalescing) operator supplies the default only when the lookup is null or undefined — safer than ||, which would also replace a legitimate 0.
The State Machine Pattern
Many systems live in exactly one of a few named states, with only certain transitions allowed. A state machine makes those states and transitions explicit, which prevents impossible situations (like a payment moving from "shipped" back to "cart").
class FetchMachine {
static TRANSITIONS = {
idle: ["loading"],
loading: ["success", "error"],
success: ["idle"],
error: ["idle"],
};
constructor() {
this.state = "idle";
}
transition(next) {
const allowed = FetchMachine.TRANSITIONS[this.state];
if (!allowed.includes(next)) {
throw new Error(`Illegal transition: ${this.state} → ${next}`);
}
console.log(`${this.state} → ${next}`);
this.state = next;
}
}
const m = new FetchMachine();
m.transition("loading"); // ok
m.transition("success"); // ok
// m.transition("loading"); // throws: illegal from "success"
State machines power form wizards, game character states, authentication flows, and order lifecycles — anywhere "what can happen next" depends on "where we are now."
Filter-Map-Reduce
A huge share of data work is really three steps: select the items you care about, transform them, then combine them into a result. Chaining filter → map → reduce expresses that pipeline so each stage's intent is obvious.
const orders = [
{ id: 1, status: "completed", total: 85.65, items: 2 },
{ id: 2, status: "pending", total: 23.82, items: 1 },
{ id: 3, status: "completed", total: 125.99, items: 3 },
{ id: 4, status: "cancelled", total: 49.95, items: 2 },
];
// Total value of completed multi-item orders
const revenue = orders
.filter(o => o.status === "completed")
.filter(o => o.items > 1)
.map(o => o.total)
.reduce((sum, t) => sum + t, 0);
console.log(revenue.toFixed(2)); // "211.64"
💡 Readability vs. performance
Each chained method walks the array once, so a long chain makes several passes. For everyday data sizes that's irrelevant and the clarity wins. If you're processing millions of records in a hot path, a single loop (or one reduce) can be worth it — measure before optimising.
Error Handling Patterns
Robust apps expect things to go wrong. Three patterns cover most cases.
try / catch / finally
Attempt risky work in try, handle failures in catch, and run cleanup in finally — which executes whether or not an error occurred.
async function readUser(db, id) {
let connection = null;
try {
connection = await db.open();
return await connection.query("SELECT * FROM users WHERE id = ?", [id]);
} catch (error) {
console.error(`Failed to read user ${id}: ${error.message}`);
throw error; // rethrow if we can't recover here
} finally {
if (connection) await connection.close(); // always runs
}
}
Custom error types
Subclassing Error lets callers branch on the kind of failure with instanceof, not by string-matching messages.
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
class AuthError extends Error {
constructor(message) {
super(message);
this.name = "AuthError";
}
}
function login(username, password) {
if (!username || !password) {
throw new ValidationError("Username and password are required");
}
const user = authenticate(username, password);
if (!user) throw new AuthError("Invalid credentials");
return user;
}
try {
login("", "");
} catch (error) {
if (error instanceof ValidationError) console.log("Fix your input:", error.message);
else if (error instanceof AuthError) console.log("Login blocked:", error.message);
else throw error; // unknown — let it bubble up
}
⚠️ Only catch what you can handle
Swallowing every error with an empty catch hides real bugs. Catch specifically, add context, and rethrow anything you can't meaningfully recover from so a higher layer can decide.
Async Control Flow
Network calls, file reads, and timers don't finish instantly. async/await lets you write asynchronous steps in a straight, readable line while they still run without blocking the page.
async function updateProfile(userId, updates) {
try {
const user = await fetchUser(userId);
const merged = { ...user, ...updates };
const valid = await validate(merged);
return await save(valid);
} catch (error) {
console.error("Update failed:", error.message);
throw error;
}
}
When steps don't depend on each other, run them together with Promise.all instead of awaiting one at a time — the total wait drops to the slowest single request rather than their sum.
async function loadDashboard(userId) {
// Kick off all three at once, then wait for all
const [profile, posts, notifications] = await Promise.all([
fetchProfile(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
return {
profile,
recentPosts: posts.slice(0, 5),
unread: notifications.filter(n => !n.read),
};
}
✅ Async best practices
- Wrap
awaitcalls intry/catchto handle rejections. - Use
Promise.allfor independent work; keep sequentialawaitonly when one step needs the previous result. - Remember an
asyncfunction always returns a Promise, even when youreturna plain value.
Hands-on Exercise
🏋️ A Mini Order Processor
Objective: Combine three patterns — guard clauses, a state machine, and async/await — in one small module.
Instructions:
- Define legal order transitions:
cart → checkout,checkout → paid,paid → shipped, andcheckout → cart(to cancel back). - Write
advance(order, next). Start with guard clauses: reject a missing order, an unknownnextstate, and any transition not in the allowed map. - Make the "checkout → paid" step
async:awaita mockchargeCard(order)that resolves after a short delay, and only then update the state. - Wrap the payment in
try/catchso a failed charge leaves the order incheckoutand reports the error.
💡 Hint
Keep the transition map as an object: { cart: ["checkout"], checkout: ["paid","cart"], paid: ["shipped"] }. Only the "paid" transition needs the async charge; the others can update state synchronously.
✅ Sample solution
const TRANSITIONS = {
cart: ["checkout"],
checkout: ["paid", "cart"],
paid: ["shipped"],
shipped: [],
};
function chargeCard(order) {
return new Promise((resolve, reject) => {
setTimeout(() => {
order.total > 0 ? resolve(true) : reject(new Error("Empty order"));
}, 200);
});
}
async function advance(order, next) {
// Guard clauses
if (!order) throw new Error("No order provided");
if (!TRANSITIONS[next] && next !== "shipped") throw new Error(`Unknown state: ${next}`);
if (!TRANSITIONS[order.state].includes(next)) {
throw new Error(`Illegal transition: ${order.state} → ${next}`);
}
// Async payment step
if (order.state === "checkout" && next === "paid") {
try {
await chargeCard(order);
} catch (error) {
console.error("Payment failed:", error.message);
return order.state; // stays in "checkout"
}
}
order.state = next;
console.log(`Order now: ${order.state}`);
return order.state;
}
// Demo
(async () => {
const order = { state: "cart", total: 42 };
await advance(order, "checkout");
await advance(order, "paid");
await advance(order, "shipped");
})();
🎯 Quick Quiz
Question 1: What is the main benefit of the guard clause pattern?
Question 2: When should you use Promise.all instead of sequential await?
Question 3: Why define custom error classes that extend Error?
Summary & Quiz
🎉 Key Takeaways
- Guard clauses return early on failure, flattening validation-heavy functions.
- Lookup tables replace value-mapping switches; keep
switchfor multi-statement or fall-through cases. - State machines make legal transitions explicit and forbid impossible ones.
- Filter-map-reduce expresses select → transform → combine as a readable pipeline.
- try/catch/finally plus custom error types give structured, recoverable error handling.
- async/await reads sequentially;
Promise.allparallelises independent work.
📚 Further Reading
- MDN — Control flow and error handling
- MDN — Using promises
- MDN — async function
- JavaScript.info — Promises, async/await
🚀 What's Next?
Patterns give shape to logic; the next step is packaging that logic for reuse. Up next: Function Declaration and Expressions — how to define, name, and pass around the functions these patterns are built from.
🎉 Great progress!
You can now recognise the shapes behind clean JavaScript. On to functions.