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 flatten the happy path
Section titled “Guard clauses flatten the happy path”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.
const chargeCustomer = (input: ChargeInput) => { if (!input.user) return { ok: false, reason: 'unauthenticated' }; if (!input.user.canCharge) return { ok: false, reason: 'permission' }; if (input.amountCents <= 0) return { ok: false, reason: 'amount' }; if (input.user.isFrozen) return { ok: false, reason: 'frozen' }; return processCharge(input);};Flat: each invalid case returns at the top, the happy path unindented at the bottom. Every if returns immediately, so no undefined can leak.
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.
Drop the dead else
Section titled “Drop the dead else”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.
return user.isAdmin ? user.adminBadge : null;The ternary reads as the contract: returns either the admin badge or null.
<Button variant={isDestructive ? 'danger' : 'primary'}>Delete</Button>The prop wants a value, 'danger' or 'primary', not a statement.
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);Nested ternaries
Section titled “Nested ternaries”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.
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 fallthrough safety net
Section titled “The fallthrough safety net”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);}return, every variant is handled, and payment has narrowed to never at the default, so assertNever(payment) type-checks. The last is the same, with throw as the terminator. The second fails: case 'pending': runs log('pending') but never terminates, so the compiler errors on the fallthrough into 'paid'. The third fails: 'failed' is unhandled, so at the default payment is still { status: 'failed'; reason: string }, not never, and assertNever(payment) is rejected. The pattern doesn’t care whether you return or throw, only that every case terminates and every variant has a home.The lookup-map alternative
Section titled “The lookup-map alternative”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.
const color = { paid: 'green', pending: 'yellow', failed: 'red' }[status] ?? 'gray';One line. The object literal is the mapping, the indexing is the lookup. The ?? 'gray' covers a status that matches no key: under noUncheckedIndexedAccess the lookup’s type is string | undefined, so the fallback is required.
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.
Which loop to reach for
Section titled “Which loop to reach for”JavaScript has four loop forms. One is the default, two are situational, one you never write.
-
.map/.filter/.reducearray methods. The default for turning a list into a new list: expression-shaped, no mutable accumulator, types thread through automatically. -
for...of, for side-effecting or async iteration: database writes per item, awaiting work per item, earlybreakon a match.breakandcontinueare clean here, where.some/.everycan’t host a multi-statement or async body. Usefor (const [index, value] of arr.entries())when you need the index, andfor (const [key, value] of Object.entries(obj))to walk an object’s own keys. -
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. -
for...in, the legacy form. It iterates enumerable string keys, including inherited ones from the prototype chain, which is almost always wrong; useObject.keys,Object.entries, orObject.valuesinstead. The course never writesfor...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.
Spot all four smells in one function
Section titled “Spot all four smells in one function”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.
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 nested-if smell: two if/else levels wrap the dispatch, with the happy path at indentation three and the error returns buried in trailing else blocks. Flip each check into a guard:
if (!event) return { ok: false, reason: 'no-event' };if (event.amountCents <= 0) return { ok: false, reason: 'amount' };Two guards at the top dedent the rest by two levels. Biome’s noUselessElse offers the fix on save.
The ternary-for-side-effects smell: the result is discarded, so the ternary is choosing what to do, not what value to produce. Use an if:
if (event.kind === 'charge') recordCharge(event);else recordOther(event);The missing-case smell: BillingEvent has three variants, the switch handles two, and with no default the gap fails silently. Add the case and an assertNever default:
case 'chargeback': totals.chargebacks = (totals.chargebacks ?? 0) + event.amountCents; break;default: return assertNever(event);Now the next forgotten variant is a compile error, not a silent bug.
The for...in-on-an-object smell: it walks inherited prototype keys, so anything added to Object.prototype corrupts the total. Use Object.entries:
for (const [, value] of Object.entries(totals)) { total += value;}Or collapse the loop entirely with Object.values(totals).reduce((sum, v) => sum + v, 0).
Each smell may compute the right answer today; what you flag is the shape, spotted from the diff line alone before you read the implementation. The wrong shape charges interest on every future read of the file.
External resources
Section titled “External resources”The Biome rule that flags `else` blocks after an `if` that ends in `return`, `throw`, `continue`, or `break`, with an autofix.
The official TypeScript reference for discriminated unions and the `never` exhaustiveness pattern.
The refactoring catalog entry for guard clauses, with before/after code in five languages.
The reference for `for...in`, including the prototype-chain behavior behind the bug the loops section warns about.