Skip to content
Chapter 2Lesson 4

Guards, ternaries, and exhaustive switch

The JavaScript and TypeScript control-flow forms that keep function bodies flat.

Open any legacy codebase and you’ll find functions four levels of if/else deep, the happy path buried at the bottom. Following them means tracking which else belongs to which if. The real danger, though, is the branch that gets missed silently: a new variant slips into a type, no branch handles it, no compile error fires, and a function that should return a string starts returning undefined in production.

No single trick fixes this. You reach for one of three structural forms depending on the branching, plus a 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, where the branch chooses what value to assign, return, or pass, not what to do. Exhaustive switch handles discriminated dispatch: a fixed set of branches on a literal field, where the compiler catches a missing case. Every function in the rest of this course uses these three shapes.

Guard clauses are the form you’ll reach for most often. Here is the same function two ways: same four checks, same five returns, only the structure differs.

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 levels deep, the happy path buried at the bottom. You have to match each else to its if to know what runs when, and forgetting a return in any branch leaks an implicit undefined with no compile error. Every level adds another place for that bug to hide.

The two versions differ by a polarity flip. The nested one reads “if everything is good, do the work, else handle errors”; the guard version reads “if anything is wrong, exit, then do the work.” That is what a guard clause is: a check at the top of a function that returns early on an invalid or edge case, with no else, so the rest of the body can assume the happy path. Each new precondition then adds one guard at the top instead of another level of nesting.

Once a branch returns or throws, its else is dead weight: the code after the if runs only when the if didn’t return, so the else disambiguates nothing. Drop it and dedent.

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

If the if returned, the else branch is unreachable; if it didn’t, that branch is the only path left. Either way the else says nothing the form below doesn’t.

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

Biome’s noUselessElse rule flags every else that follows an if ending in return, throw, continue, or break, and its autofix dedents the trailing block in one keystroke.

The broader habit: treat nesting deeper than two levels as a smell. A reviewer who opens a body nested three or four deep refactors into guards before reading further. The conditions might be correct; it’s the shape being fixed, because the nesting taxes every future reader.

Ternaries select values, never side effects

Section titled “Ternaries select values, never side effects”

Use a ternary when the result is a value you assign, return, or pass as an argument; never for side effects. When the branch decides “this value or that value,” the ternary reads like the data. When it decides “do A or do B,” an if reads better.

A ternary fits in three value-selection slots:

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

The two branches are the two possible values of label.

The anti-pattern uses the same operator for side effects:

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

This runs one of the calls and discards the result, but the shape says “pick a value” when the intent is “pick which function to call,” so every reader has to translate it. An if says it directly:

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

Acceptable when the structure reads at a glance, refactor when a branch has to be parsed. Shape decides, not 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 visible from indentation alone: three cases, three colors. Past three or four cases on a single field, the lookup-map form usually reads better; you’ll see it in the next section.

What earns the refactor is a branch that is itself an expression to parse:

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

Three branches on one line, each with non-trivial arithmetic, no structure visible without re-reading. The fix is a switch (next section) or a named helper:

const fee = computeFee(type, amount);

Biome’s noUselessTernary flags shapes like x ? true : false that collapse to x. It does not flag ternaries used for side effects, so catch those in review.

Exhaustive switch for discriminated dispatch

Section titled “Exhaustive switch for discriminated dispatch”

Reach for an exhaustive switch when three things hold: 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: a type that is one of several object variants, each tagged by a shared literal field, like a status on a Payment or a type on a webhook payload. When the cases 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.

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 shares a status field with a unique literal value and carries its own per-case fields (paid has amountCents, failed has reason). That shared field is the discriminant , the field the switch dispatches on. This is the canonical way to model “one of these N states.”

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. Its parameter is typed never , the type with no possible value; if it is ever actually called, it throws. Write it once in lib/assert-never.ts and import it into every switch over a discriminated union.

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 switch dispatches on payment.status, the discriminant, and each case matches one of its literal values. 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, so no break is needed. Inside each case TypeScript narrows payment by the discriminant: in case 'paid' it knows the variant carries amountCents, so payment.amountCents type-checks; in case 'failed' that same access is a compile error, because that variant lacks the field.

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 payoff. By the default, every variant is handled, so TypeScript has narrowed payment to never and assertNever(payment) type-checks. Add { status: 'refunded'; refundedAt: Date } to Payment and forget its case, and at the default payment narrows to the unhandled refunded variant instead of never, so assertNever(payment) becomes a compile error. A missing case is impossible to ship.

1 / 1

The rule: every switch over a discriminated union ends with default: return assertNever(value);. One line per switch turns a forgotten case from a production bug into a build that fails at the line you missed.

The course’s tsconfig adds a second net, noFallthroughCasesInSwitch. In a C-style switch, omitting break lets execution fall through into the next case, a long-standing source of bugs. The flag makes any case that doesn’t end in break, return, throw, or continue a compile error.

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

Here case 'a' falls through and runs doB() too. Under noFallthroughCasesInSwitch the missing terminator after doA() is a compile error.

The two nets cover both failure modes. noFallthroughCasesInSwitch blocks accidental fallthrough between cases; assertNever in default blocks missing-variant bugs. Both fire at compile time.

Test yourself: which of these switch blocks compile cleanly under both? 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 reads better than a switch: the same idea, picking a value by 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 the case/return scaffolding does no work the mapping needs.

The lookup form has one watch-out. The course’s tsconfig enables noUncheckedIndexedAccess , which adds | undefined to any indexed access where the compiler can’t prove the key exists. That is why the ?? supplies a fallback when the lookup misses.

For compile-time exhaustiveness on the lookup itself, 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';

Now adding a variant to Payment['status'] makes the colors literal a compile error until you fill in the new key, the same exhaustiveness story as assertNever. The ?? 'gray' fallback is still required, because noUncheckedIndexedAccess adds | undefined even to a typed Record.

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

JavaScript has four loop forms. One is the default, two are situational, one you never write.

  1. .map / .filter / .reduce array methods. The default for turning a list into a new list: expression-shaped, no mutable accumulator, types thread through automatically.

  2. for...of, for side-effecting or async iteration: database writes per item, awaiting work per item, early break on a match. break and continue are clean here, where .some / .every can’t host a multi-statement or async body. Use for (const [index, value] of arr.entries()) when you need the index, and for (const [key, value] of Object.entries(obj)) to walk an object’s own keys.

  3. for (let i = 0; i < n; i++), the C-style index loop. Reach for it only when the index is the data: matrix indexing, custom step sizes, reverse iteration.

  4. for...in, 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: recognize it in older code, don’t replicate it.

The starter below sums an object’s values with for...in. It looks correct, but the second test fails: for...in walks the prototype chain and picks up an inherited numeric property the author never set. Rewrite it with for...of and Object.entries to pass both tests.

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.

    Object.entries returns only own enumerable properties, so for...of over its [key, value] pairs never touches the prototype chain. The for...in bug stays invisible until a teammate adds something to a shared prototype, by which point the loop has been miscounting for months.

    Start with array methods, drop to for...of for side effects or async, drop to for (let i …) when the index is the data, never drop to for...in. Ask what shape the work fits, not which syntax you prefer.

    This exercise pulls the whole lesson into one file: each reflex appears at least once. Review it, leave one inline comment per structural smell, name the class, and propose the fix.

    Review this PR. The function validates input, dispatches on a discriminated `kind`, and aggregates a result. Each structural smell from this lesson appears at least once. Flag every line a reviewer should call out, 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' };
    }
    };