Skip to main content

🧭 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 switch blocks 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").

A data-fetch state machine Idle transitions to Loading on fetch; Loading transitions to Success or Error; both return to Idle on reset. Idle Loading Success Error fetch() success error reset()
Figure 1 — A fetch workflow as a state machine. Only the drawn arrows are legal transitions; everything else is rejected.
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 filtermapreduce expresses that pipeline so each stage's intent is obvious.

flowchart LR A["[1,2,3,4,5]"] -->|filter even| B["[2,4]"] B -->|map ×2| C["[4,8]"] C -->|reduce +| D["12"]
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 await calls in try/catch to handle rejections.
  • Use Promise.all for independent work; keep sequential await only when one step needs the previous result.
  • Remember an async function always returns a Promise, even when you return a 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:

  1. Define legal order transitions: cart → checkout, checkout → paid, paid → shipped, and checkout → cart (to cancel back).
  2. Write advance(order, next). Start with guard clauses: reject a missing order, an unknown next state, and any transition not in the allowed map.
  3. Make the "checkout → paid" step async: await a mock chargeCard(order) that resolves after a short delay, and only then update the state.
  4. Wrap the payment in try/catch so a failed charge leaves the order in checkout and 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 switch for 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.all parallelises independent work.

📚 Further Reading

🚀 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.