Skip to content
Chapter 2Lesson 4

Flat control flow: guards, ternaries, and exhaustive switch

The JavaScript and TypeScript control-flow patterns that keep function bodies flat and readable, the structural shapes every function you write in this course will use.

Open any legacy codebase and you’ll find them: functions whose bodies are four levels of if/else deep, with the happy path buried at the bottom. To know what runs on which path, a reviewer has to track which else belongs to which if, and anyone debugging has to step through with breakpoints to trace the actual route through the tree. The bug this shape breeds isn’t usually the wrong condition. It’s the branch that gets missed and fails silently: an implicit undefined return at the bottom of a tree that nobody noticed was reachable. A new variant slips into a type six months later, no branch handles it, no compile error fires, and a function that’s supposed to return a string starts returning undefined in production.

There’s no single trick that fixes this. Instead, you reach for one of three structural forms depending on the kind of branching, plus one default for loops. Guard clauses handle early exits: check the invalid case first, return immediately, and run the happy path unindented. Expression-level ternaries handle value selection: reach for them when the branch is choosing what value to assign, return, or pass, not what to do. Exhaustive switch handles discriminated dispatch: a fixed number of branches on a literal field, where the compiler should catch a missing case. Every function the rest of this course writes uses these three shapes, inside the const fn = (args) => … form from “Arrow by default, declaration on demand,” and with the intent-named variables from the previous lesson, “Name for intent, not implementation.”

The four sections ahead cover one form each: guard clauses with the “no else after return” rule, ternaries with the side-effect carve-out, switch with noFallthroughCasesInSwitch and assertNever plus the lookup-map alternative, and loops in 2026. The lesson closes with a PR review where you flag every structural smell from this lesson in one short function.

Guard clauses are the first form and the one you’ll use most often. Compare the two shapes side by side: same function, same four checks, same five return paths, only the structure changes.

const chargeCustomer = (input: ChargeInput) => {
if (input.user) {
if (input.user.canCharge) {
if (input.amountCents > 0) {
if (!input.user.isFrozen) {
return processCharge(input);
} else {
return { ok: false, reason: 'frozen' };
}
} else {
return { ok: false, reason: 'amount' };
}
} else {
return { ok: false, reason: 'permission' };
}
} else {
return { ok: false, reason: 'unauthenticated' };
}
};

Four nesting levels, with the happy path buried at indentation four. The reader has to track which else belongs to which if to know what runs on which path. There’s also a quieter risk: forget a return inside any branch and an implicit undefined leaks out of that path with no compile error to warn you. Every level you add gives that bug one more place to hide.

The change between the two versions is a polarity flip. The nested version reads “if everything is good, do the work, else handle errors”; the guard version reads “if anything is wrong, exit; then do the work.” A guard clause is just that: a check at the top, an immediate return, no else. Each condition gets one return path, and the single happy path sits at the outer indentation level.

The guard form reads better for three reasons. First, every early return at the leftmost indent signals “this path is handled,” so your eye scans down the left margin and reads the function’s contract before reading any body. Second, the happy path is always last and always unindented; you know where to find it. Third, adding a new precondition doesn’t grow the nesting; it adds one more guard at the top.

Once a branch returns (or throws), the else block is dead weight. The code after the if only runs when the if didn’t return, so there’s nothing for the else to disambiguate. Drop it and dedent the rest.

const greet = (user: User | null) => {
if (!user) {
return 'hi, stranger';
} else {
return `hi, ${user.name}`;
}
};

The else here adds nothing: if the if returned, the second branch is unreachable, and if it didn’t, the second branch is the only path left. The form below says the same thing with one fewer level of structure.

const greet = (user: User | null) => {
if (!user) return 'hi, stranger';
return `hi, ${user.name}`;
};

Biome catches this for you. The noUselessElse rule flags every else block that follows an if whose body terminates with return, throw, continue, or break, and its autofix dedents the trailing block in one keystroke. So you don’t have to keep the rule in mind as you write; the linter spots the dead else on its own.

The broader habit to build here is treating indentation deeper than two levels in a 2026 SaaS function as a smell. When a reviewer opens a file and sees nesting three or four deep in a function body, they refactor into guards before reading further. The shape itself is what the reviewer is fixing: the conditions might well be correct, but the nesting costs the reader something on every future pass through the file.

Ternaries are the second form, and the rule for them is short: use a ternary when the result is a value being assigned, returned, or passed as an argument. Don’t use a ternary for side effects. The ternary’s job is value selection, not control flow. When the branch decides “do A or do B,” an if reads better. When the branch decides “this value or that value,” the ternary reads like the data.

The ternary is the right reach in three places. Each tab below shows one: same operator, different value-selection context.

const label = isPaid ? 'Paid' : 'Pending';

A value being computed and bound to a name. The two branches are the two possible values of label. The whole expression is the value of the assignment.

The anti-pattern is using the same operator for side effects:

user.isAdmin ? logAdmin(user) : logUser(user);

This works: JavaScript evaluates the ternary, picks one of the two function calls, runs it, and throws away the result. But the shape says “pick a value” when what’s actually happening is “choose which function to call.” The reader has to mentally translate the value-selection operator into a control-flow statement, and that cost is paid every time someone scans the file. The if form says what’s actually happening:

if (user.isAdmin) logAdmin(user);
else logUser(user);

The runtime behavior is identical, but the verb-shaped branching now reads as control flow, which is exactly what it is.

Nested ternaries divide opinions. The course’s position is simple: acceptable when the structure reads at a glance; refactor when any branch has to be parsed. The shape matters more than a blanket “no nesting” rule.

A flat decision tree with one-token literal branches reads as a small lookup table:

const color = status === 'paid'
? 'green'
: status === 'pending'
? 'yellow'
: 'red';

One branch per line keeps the structure of the decision visible from indentation alone. The reader scans down and sees three cases mapped to three colors. When a chain like this grows past three or four cases on a single field, the lookup-map form often reads better; you’ll see that pattern in the next section.

The form that earns the refactor is the one where each branch is itself an expression the reader has to parse:

const fee = type === 'card' ? amount * 0.029 + 30 : type === 'ach' ? amount * 0.008 : 0;

Three branches sit on one line, each with non-trivial arithmetic, and there’s no way to see the structure without re-reading. The fix is either a switch (next section) or a named helper:

const fee = computeFee(type, amount);

The trigger to refactor is when the reader can’t see the structure of the decision at a glance; at that point the ternary has outgrown its form.

A note on Biome: it does enforce noUselessTernary, which flags trivially wrong shapes like x ? true : false (the whole expression collapses to x). It does not enforce “no ternaries for side effects,” so that one is yours to catch in review.

Exhaustive switch for discriminated dispatch

Section titled “Exhaustive switch for discriminated dispatch”

Exhaustive switch is the third form and the lesson’s biggest payoff. Reach for it when three things are true: the branch count is fixed, the dispatch reads a literal field, and each case has its own logic. The classic shape is a discriminated union, like a status field on a Payment, an event.type on a webhook payload, or a kind field on a state-machine state, where each variant gets its own handling. When the branches are uniform value-to-value mappings, the lookup map at the end of this section reads better; when each case has logic, switch is the reach.

Here is the full pattern in one snippet, walked through one piece at a time.

type Payment =
| { status: 'pending'; createdAt: Date }
| { status: 'paid'; paidAt: Date; amountCents: number }
| { status: 'failed'; reason: string };
const assertNever = (x: never): never => {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
};
const describePayment = (payment: Payment): string => {
switch (payment.status) {
case 'pending':
return `Pending since ${payment.createdAt.toISOString()}`;
case 'paid':
return `Paid ${payment.amountCents} cents at ${payment.paidAt.toISOString()}`;
case 'failed':
return `Failed: ${payment.reason}`;
default:
return assertNever(payment);
}
};

The discriminated union. Each variant has a status field with a unique literal value ('pending', 'paid', 'failed'). That shared field is the discriminant : the field the switch will dispatch on. Each variant also carries its own per-case fields; paid has amountCents, failed has reason. This shape is the canonical 2026 way to model “one of these N states,” and you’ll write it for every state machine, webhook payload, and Server Action result in the rest of the course.

type Payment =
| { status: 'pending'; createdAt: Date }
| { status: 'paid'; paidAt: Date; amountCents: number }
| { status: 'failed'; reason: string };
const assertNever = (x: never): never => {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
};
const describePayment = (payment: Payment): string => {
switch (payment.status) {
case 'pending':
return `Pending since ${payment.createdAt.toISOString()}`;
case 'paid':
return `Paid ${payment.amountCents} cents at ${payment.paidAt.toISOString()}`;
case 'failed':
return `Failed: ${payment.reason}`;
default:
return assertNever(payment);
}
};

assertNever is the compile-time exhaustiveness helper. It takes one parameter typed never, TypeScript’s never type , the bottom type that represents “no possible value.” If the function is ever actually called, it throws at runtime. You write it once per codebase, in lib/assert-never.ts, and import it everywhere a switch over a discriminated union appears. The chapter on TypeScript narrowing explains why never does the compile-time work; the final step of this walkthrough shows the behavior in action.

type Payment =
| { status: 'pending'; createdAt: Date }
| { status: 'paid'; paidAt: Date; amountCents: number }
| { status: 'failed'; reason: string };
const assertNever = (x: never): never => {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
};
const describePayment = (payment: Payment): string => {
switch (payment.status) {
case 'pending':
return `Pending since ${payment.createdAt.toISOString()}`;
case 'paid':
return `Paid ${payment.amountCents} cents at ${payment.paidAt.toISOString()}`;
case 'failed':
return `Failed: ${payment.reason}`;
default:
return assertNever(payment);
}
};

The function signature returns string and takes a Payment. The switch dispatches on payment.status, the discriminant from step 1. Each case matches one literal value of that field. The structure is flat: one case per variant, no nesting.

type Payment =
| { status: 'pending'; createdAt: Date }
| { status: 'paid'; paidAt: Date; amountCents: number }
| { status: 'failed'; reason: string };
const assertNever = (x: never): never => {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
};
const describePayment = (payment: Payment): string => {
switch (payment.status) {
case 'pending':
return `Pending since ${payment.createdAt.toISOString()}`;
case 'paid':
return `Paid ${payment.amountCents} cents at ${payment.paidAt.toISOString()}`;
case 'failed':
return `Failed: ${payment.reason}`;
default:
return assertNever(payment);
}
};

Every case returns; there’s no break because return already exits the function. TypeScript narrows payment inside each case based on the discriminant: inside case 'paid', the compiler knows payment is the { status: 'paid'; paidAt; amountCents } variant specifically, so reading payment.amountCents type-checks. Inside case 'failed', accessing payment.amountCents would be a compile error because that variant doesn’t have the field. The full narrowing model comes in the chapter on TypeScript narrowing; what you need here is that the compiler picks the right variant per case automatically.

type Payment =
| { status: 'pending'; createdAt: Date }
| { status: 'paid'; paidAt: Date; amountCents: number }
| { status: 'failed'; reason: string };
const assertNever = (x: never): never => {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
};
const describePayment = (payment: Payment): string => {
switch (payment.status) {
case 'pending':
return `Pending since ${payment.createdAt.toISOString()}`;
case 'paid':
return `Paid ${payment.amountCents} cents at ${payment.paidAt.toISOString()}`;
case 'failed':
return `Failed: ${payment.reason}`;
default:
return assertNever(payment);
}
};

This is the payoff. The default case calls assertNever(payment). Every other variant has been handled by the cases above, so TypeScript has narrowed payment to never here: the type with no possible value, because there’s nothing left. The call type-checks today. Now suppose someone adds a fourth variant to Payment tomorrow, say { status: 'refunded'; refundedAt: Date }, and forgets to add a case 'refunded': to this switch. At the default, payment no longer narrows to never; it narrows to the unhandled refunded variant. The assertNever(payment) call becomes a compile error: “Argument of type { status: 'refunded'; ... } is not assignable to parameter of type never.” A missing case becomes structurally impossible to ship.

1 / 1

The rule is short: every switch over a discriminated union ends with default: return assertNever(value);. That single line turns an exhaustiveness bug from “production silently returns the wrong thing six months later” into “the build fails at the line you forgot to update.” It’s one of the highest-leverage type-system patterns the course teaches, and it costs you one extra line per switch.

There’s a second safety net the course’s tsconfig enables: noFallthroughCasesInSwitch. In classic C-style switch, omitting break lets execution fall through into the next case, a long-standing source of bugs in every language that inherited the operator. The flag (configured in the chapter on TypeScript strict mode) makes any case that doesn’t terminate with break, return, throw, or continue a compile error.

switch (kind) {
case 'a':
doA();
case 'b':
doB();
break;
}

Under noFallthroughCasesInSwitch, the missing terminating statement after doA() is a compile error, because execution would fall through into case 'b' and run doB() too. The flag turns the classic “I forgot the break” bug into a build failure; the only way to silence it is to add a real terminator (break, return, throw, or continue).

Together, the two safety nets cover both failure modes: noFallthroughCasesInSwitch blocks accidental fallthrough between cases; assertNever in default blocks missing-variant exhaustiveness bugs. Both fire at compile time. Neither needs you to remember anything at the line where you write the switch; the tools reject the broken shapes.

Test yourself: which of the following switch blocks compile cleanly under noFallthroughCasesInSwitch and assertNever? More than one is correct.

Given the Payment type and assertNever helper from the snippet above, which of these switch blocks compile under noFallthroughCasesInSwitch + assertNever? Select all that apply.

switch (payment.status) {
case 'pending':
return 'p';
case 'paid':
return 'paid';
case 'failed':
return 'failed';
default:
return assertNever(payment);
}
switch (payment.status) {
case 'pending':
log('pending');
case 'paid':
return 'paid';
case 'failed':
return 'failed';
default:
return assertNever(payment);
}
switch (payment.status) {
case 'pending':
return 'p';
case 'paid':
return 'paid';
default:
return assertNever(payment);
}
switch (payment.status) {
case 'pending':
throw new Error('pending');
case 'paid':
return 'paid';
case 'failed':
return 'failed';
default:
return assertNever(payment);
}

When the cases are uniform value-to-value mappings with no logic per case, an object literal lookup reads better than a switch. It’s the same idea, picking a value based on a discriminant, without the case/break/return scaffolding.

const colorFor = (status: Payment['status']): string => {
switch (status) {
case 'paid':
return 'green';
case 'pending':
return 'yellow';
case 'failed':
return 'red';
default:
return assertNever(status);
}
};

Verbose for a flat mapping. Eleven lines for three cases, and none of the case/return scaffolding does any work the mapping itself needs. The assertNever payoff is real, but for a pure value-to-value mapping the form is heavier than the job calls for.

The lookup form has one watch-out. The course’s tsconfig enables noUncheckedIndexedAccess , which adds | undefined to the type of every indexed access where the compiler can’t prove the key exists. That’s why the ?? operator (covered in the next lesson on ?? and ||) shows up: it supplies the fallback when the lookup misses. Without the fallback, the variable’s type would be string | undefined and TypeScript would force you to handle the miss everywhere it’s used.

If you want compile-time exhaustiveness on the lookup itself, there’s a tighter alternative: type the map as a record indexed by the union, so TypeScript refuses to compile until every key is present.

const colors: Record<Payment['status'], string> = {
paid: 'green',
pending: 'yellow',
failed: 'red',
};
const color = colors[status] ?? 'gray';

Record<Keys, Value> is one of TypeScript’s built-in mapped types; the full treatment lands in the chapter on object types and records. Once the map is typed against the union, adding a new variant to Payment['status'] makes the colors literal a compile error until you fill in the new key. That’s the same exhaustiveness story as assertNever, applied to the lookup-map form. The ?? 'gray' fallback is still required at the access site, because noUncheckedIndexedAccess adds | undefined to any dynamic indexed lookup, even against a typed Record.

The trade comes down to one sentence: a lookup map is shorter and cleaner for uniform value-to-value mappings; switch + assertNever is the right reach when each case has logic and the compiler should enforce exhaustiveness.

Four loop forms exist in modern JavaScript. The course writes one of them most of the time, two of them sometimes, and one of them never. Here they are, ranked by how often you’ll reach for them:

  1. .map / .filter / .reduce array methods. These are the default for any list-to-list transformation: expression-shaped, no mutable accumulator, and the types thread through automatically. A later chapter on container-shaped data covers them in depth; for this lesson, know that they’re the first reach for transforming an array into a new array.

  2. for...of is the reach for side-effecting iteration: database writes inside a loop body, async work that has to await per item, early break to stop on a match, anything the array methods aren’t shaped for. Use for (const [index, value] of arr.entries()) when the index is needed, and for (const [key, value] of Object.entries(obj)) to iterate an object’s own enumerable keys safely. break and continue are clean here; reach for them when the body has multiple statements or async calls that .some / .every can’t host.

  3. for (let i = 0; i < n; i++) is the C-style index loop. Its narrow trigger is numeric ranges where the index is the data: matrix indexing, custom step sizes, reverse iteration before .toReversed() was available. It’s rare in 2026 SaaS code; when you do reach for it, the index is doing semantic work, not just counting positions.

  4. for...in is the legacy form. It iterates enumerable string keys, including inherited ones from the prototype chain, which is almost always wrong; use Object.keys, Object.entries, or Object.values instead. The course never writes for...in. You’ll see it in older third-party code; recognize it, don’t replicate it.

The bug class for...in introduces is worth seeing once before you build the reflex to avoid it. The starter below uses for...in to sum the values of a plain object, and it looks correct in isolation. The tests pin the actual behavior: the second test fails because for...in walks up the prototype chain and picks up an inherited numeric property the author never put on the object. Rewrite the function to use for...of + Object.entries and watch both tests pass.

Rewrite sumValues to iterate only the object's own enumerable keys using Object.entries and for...of. The second test pins the bug the original ships: when the object's prototype carries a numeric property, the for...in form picks it up too.

    The fix isn’t sophisticated; it’s the default reach. Object.entries returns only the object’s own enumerable string-keyed properties, and for...of iterates the resulting [key, value] pairs without touching the prototype chain. The for...in bug stays invisible until the day a teammate adds something to a shared prototype, and by then the loop has been quietly miscounting for months.

    The deeper pattern across the four loop forms is that each later form is a niche escape from the default. Start with array methods; drop to for...of when the body needs side effects or async; drop to for (let i …) when the index is the data; never drop to for...in. The question to ask is “what shape does the work fit,” not “which loop syntax do I prefer today.”

    The closing exercise brings the whole lesson together. Each reflex appears at least once in the file below: guard clauses for early exits, ternaries only at the expression level, exhaustive switch with assertNever, and never for...in. Review the file, leave one inline comment per structural smell, name the class, and propose the fix.

    Review this PR. The function does three jobs — validates input, dispatches on a discriminated `kind`, and aggregates a result. Each structural smell from this lesson appears at least once. Flag every line where a reviewer should call out a structural issue, name the class, and propose the fix. Click any line to leave a review comment, then press Submit review.

    src/billing/process-event.ts
    import { assertNever } from '@/lib/assert-never';
    type BillingEvent =
    | { kind: 'charge'; amountCents: number }
    | { kind: 'refund'; amountCents: number }
    | { kind: 'chargeback'; amountCents: number; reason: string };
    export const processEvent = (event: BillingEvent | null, totals: Record<string, number>) => {
    if (event) {
    if (event.amountCents > 0) {
    event.kind === 'charge' ? recordCharge(event) : recordOther(event);
    switch (event.kind) {
    case 'charge':
    totals.charges = (totals.charges ?? 0) + event.amountCents;
    break;
    case 'refund':
    totals.refunds = (totals.refunds ?? 0) + event.amountCents;
    break;
    }
    let total = 0;
    for (const key in totals) {
    total += totals[key];
    }
    return { ok: true, total };
    } else {
    return { ok: false, reason: 'amount' };
    }
    } else {
    return { ok: false, reason: 'no-event' };
    }
    };

    The reflex this exercise builds is seeing the shape of the smell before you read the implementation. Indentation three or four levels deep calls for guard clauses. A ternary with a thrown-away result is an if/else in disguise. A switch without default: return assertNever(value); is a production bug scheduled for the day someone adds a new variant. A for...in over an object is a prototype-chain bug waiting for the next library upgrade to surface it. Once these four reflexes fire on autopilot, you spot the bug class from the diff line alone.