Steven's Knowledge

Control Flow

Conditionals, loops, early returns, and table-driven alternatives

Control Flow

Most defects live in control flow — in the branches not taken, the loop that ran one time too many, the case that nobody anticipated. Code with simple, predictable control structures is easier to read, easier to test, and easier to fix.

Straight-Line Code

The simplest control flow is none at all. When statements have dependencies, order them so the dependencies are obvious:

  • Group statements that work on the same data.
  • Order independent groups so the reading flow is top-to-bottom.
  • Initialize each variable close to its first use.

If the statements have no required order, the function is probably doing several unrelated things and should be split.

Conditionals

Use positive forms

A condition is easier to read in its affirmative form:

if (isReady)         // ✓
if (!isNotReady)     // ✗ — double negative

This applies to both the predicate and the branch order. Put the more common, more meaningful, or affirmative branch first when both branches are similar in size.

Use guard clauses for early exits

Nested conditionals push the main logic to the deepest level, where it is hardest to find. Convert preconditions to guards:

// Nested
function pay(employee) {
  if (employee.isActive) {
    if (employee.hasTimesheet) {
      // long calculation
      return ...;
    } else {
      return zeroPay('NO_TIMESHEET');
    }
  } else {
    return zeroPay('INACTIVE');
  }
}

// Guarded
function pay(employee) {
  if (!employee.isActive)     return zeroPay('INACTIVE');
  if (!employee.hasTimesheet) return zeroPay('NO_TIMESHEET');
  // long calculation
  return ...;
}

The main logic stays at the top level; the special cases are visible as a stack at the entrance.

Avoid deep nesting

Three levels of nested control structures inside a function is roughly the limit before readers lose the plot. When you find yourself going deeper, the options are:

  • Invert a condition into a guard.
  • Extract the inner block into its own function.
  • Replace the conditional structure with polymorphism or a table.

Name complex predicates

A compound boolean expression should be named when it is read multiple times or when its meaning is non-obvious:

// Hard to scan
if (date < SUMMER_START || date > SUMMER_END || isHoliday(date)) { ... }

// Self-documenting
const isOffPeak = date < SUMMER_START || date > SUMMER_END || isHoliday(date);
if (isOffPeak) { ... }

The new name is a free comment that the type system checks.

switch and exhaustiveness

A switch is a code smell when:

  • The same switch shape appears in multiple places (replace with polymorphism).
  • New cases are added frequently (replace with a registry or table).
  • The default branch has no defined behavior (use an exhaustiveness check; in TypeScript, assertNever; in functional languages, the compiler).

A switch is fine when the cases are stable and exhaustively known at the point of use, and when there is only one such switch in the codebase.

Loops

Choose the right loop

LoopUse when
for-of / forEachIterating a collection without index
for (counted)Index matters; bounded iterations
whileCondition-driven; iteration count not known
do-whileBody must execute at least once
Higher-order (map, filter, reduce)Pure transformations

Reach for the highest-level form that captures the intent. A map says "transform each"; a for loop with a manual append says the same thing in three lines that the reader has to parse.

One job per loop

A loop that builds a result, sends a notification, and logs something is doing too much. Each concern obscures the others. Split into separate passes when possible — modern hardware makes the cost of two passes negligible compared to the cost of obscure code.

Keep loop bodies short

A loop body longer than ten lines is a candidate for extraction. The extracted function gets a name; the loop reads as for each X, doSomethingTo(X).

Manage termination explicitly

An off-by-one error is the difference between iterating to < n and <= n; a forever loop is the difference between updating the counter and forgetting to. A few habits prevent most cases:

  • Prefer half-open ranges ([start, end)) consistently.
  • Initialize the counter at the loop, not far away.
  • Update the counter at the end of the body, not in the middle.
  • Test boundary conditions explicitly (empty, one-element, exact-size).

break, continue, and early return

These were once controversial; they are now standard. The rule is the same as for guard clauses: an early exit that cleans up the rest of the body is a readability win. An early exit that hides a critical step is a defect waiting to happen.

When a loop becomes complex enough that you find yourself nesting break flags or labeled breaks, the loop is doing too much. Extract it.

Recursion

Recursion is right when:

  • The problem is naturally recursive (trees, parsing, divide-and-conquer).
  • The recursion depth is bounded by the data, not by user input.
  • The language supports tail-call optimization, or the depth is small enough that stack overflow is not a concern.

Iteration is right when:

  • The problem is naturally sequential.
  • Stack depth could become large.
  • The recursion would be artificial — turning a for loop into a recursive call buys nothing.

Avoid mixing iteration and recursion in the same function unless it is the natural shape of the algorithm.

Table-Driven Methods

When a control structure is large, repetitive, or expected to change frequently, replace the code with a data table.

// Before — 30 cases of similar branching
function shippingCost(country, weight) {
  if (country === 'US')      return weight * 0.50 + 5;
  if (country === 'CA')      return weight * 0.60 + 7;
  if (country === 'UK')      return weight * 0.80 + 12;
  // ...
}

// After — data is data, code is code
const SHIPPING = {
  US: { perKg: 0.50, base:  5 },
  CA: { perKg: 0.60, base:  7 },
  UK: { perKg: 0.80, base: 12 },
  // ...
};

function shippingCost(country, weight) {
  const r = SHIPPING[country] ?? throwUnknown(country);
  return weight * r.perKg + r.base;
}

The table form scales: adding a country is one entry, not one branch. It also makes the rates testable, swappable, and loadable from configuration.

Table-driven methods replace:

  • Long if/else chains over a single variable.
  • switch statements that map values to constants.
  • Coordinated if chains spread across several functions.

Exceptions vs. Error Codes vs. Optional Returns

The choice of failure channel affects control flow:

  • Exceptions unwind the stack to a handler. They keep the happy path linear but obscure the failure path.
  • Error codes / Result types make failure explicit at every call site. They clutter the happy path but force the caller to consider both branches.
  • Optional / nullable returns model "no result," which is different from "operation failed."

Use each for what it is good at:

  • Use a Result or sum type for expected failures the caller must handle (validation, parse errors, I/O).
  • Use exceptions for unexpected failures or for errors that should propagate through several layers without obscuring intermediate code.
  • Use Optional for "this query may have no answer," distinct from failure.

Mixing all three in the same module produces code where every call site has to ask three questions before getting to the answer.

State Machines

When a class or function has more than two or three flag-like booleans, it is implementing a state machine implicitly. Make the state machine explicit:

  • Enumerate the states.
  • Enumerate the transitions and their triggers.
  • Reject transitions that the model does not allow.

A four-line state diagram in a comment, paired with a small transition() function, replaces a tangle of if (isPaid && !isShipped && !isCancelled) checks scattered through the codebase.

Pre-Commit Checklist

  • Are conditionals expressed in their positive form?
  • Are early-exit guards used to keep the main logic at the top level?
  • Is the function free of nesting beyond three levels?
  • Are complex boolean expressions named?
  • Is each loop doing one job, with a body short enough to read at a glance?
  • Could a long if/else chain or repetitive switch be replaced with a table?
  • Is the choice of failure channel (exception, Result, Optional) deliberate and consistent within the module?

On this page