if, else if, and else;switch is clearer than a chain of ifs, and the fall-through trap to avoid;switch altogether;Learn JS Series):As always, we open with worked solutions to last episode's three exercises. Type them out, run them, and compare them against your own attempts -- the small gaps between "I think this works" and "I watched it print the right thing" are exactly where real understanding gets built.
Exercise 1 - epsilon comparison in action:
function almostEqual(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) < epsilon;
}
console.log(almostEqual(0.1 + 0.2, 0.3)); // true
console.log(0.1 + 0.2 === 0.3); // false
The insight: the two values differ by a tiny amount below Number.EPSILON, so almostEqual treats them as equal while === sees the microscopic difference and reports false. This is the standard way to compare decimal results in every language that uses IEEE 754 floats.
Exercise 2 - a cart computed in cents:
const itemCents = 495; // 4.95 EUR
const qty = 7;
const totalCents = itemCents * qty; // 3465, exact
console.log(`${(totalCents / 100).toFixed(2)} EUR`); // "34.65 EUR"
The insight: 495 * 7 is exact integer arithmetic, so no floating-point drift can creep in. We only divide by 100 at the very end, purely for display. Doing 4.95 * 7 as plain floats would risk a trailing rounding error, which is exactly what you do not want anywhere near money.
Exercise 3 - a real-number check:
function isRealNumber(x) {
return Number.isFinite(x);
}
console.log(isRealNumber(42)); // true
console.log(isRealNumber(NaN)); // false
console.log(isRealNumber(Infinity)); // false
console.log(isRealNumber(Number("abc"))); // false
The insight: Number.isFinite rejects NaN and Infinity in one shot. You cannot write x === NaN because NaN is not equal to anything, itself included, so that test is always false.
Right. Up to now our programs have run straight from top to bottom, every line every time. That is fine for a calculator that always does the same sum, but real programs need to decide. Show the warning only when the disk is nearly full. Charge the member nothing and the guest twenty-five euro. Pick one of five branches based on a status code. This episode is the whole toolkit for making those decisions: if, switch, and the ternary -- plus the professional habits that keep decision-heavy code from turning into an unreadable pyramid.
The workhorse of control flow is if. It runs a block only when its condition is truthy:
const temperature = 28;
if (temperature > 25) {
console.log("It is warm out.");
}
Add else for the "otherwise" case, and chain else if for multiple branches. JavaScript checks them top to bottom and runs the first match, then skips the rest entirely:
const score = 72;
if (score >= 90) {
console.log("A");
} else if (score >= 75) {
console.log("B");
} else if (score >= 60) {
console.log("C");
} else {
console.log("F");
}
// prints "C" - the first matching branch
Order matters here, and it matters a lot. Because JavaScript stops at the first truthy branch, you must arrange your conditions from most specific (or highest) to least. If you accidentally wrote score >= 60 first, then everyone scoring above 60 would get a "C", because that branch would win before the >= 90 and >= 75 tests ever got a look in. This is one of the most common beginner bugs, and it produces no error at all -- just quietly wrong output, which is worse.
One subtlety worth internalising early: an else if chain is genuinely different from a stack of separate if statements. A chain is one decision with several outcomes, and at most one branch runs. Separate ifs are several independent decisions, and any number of them can run:
const n = 4;
// chain: exactly one branch runs
if (n > 0) console.log("positive");
else if (n > 3) console.log("big"); // never reached, the first if already won
// separate ifs: both can run
if (n > 0) console.log("positive again");
if (n > 3) console.log("big again"); // this one runs too
When the cases are mutually exclusive, use a chain. When they are genuinely independent tests, use separate ifs. Choosing the wrong one is a classic source of "why did both messages print?" confusion.
Here is something that trips up nearly everyone coming from a stricter language. An if does not require an actual boolean. It happily takes any value and converts it to true or false first, using the coercion rules we met in episode 4. So to reason about your own code, you need to know exactly which values count as false. The good news: the falsy list is short and memorizable. There are exactly eight falsy values:
if (false) {} // false
if (0) {} // the number zero (and -0)
if (0n) {} // bigint zero
if ("") {} // the empty string
if (null) {} // null
if (undefined) {} // undefined
if (NaN) {} // NaN
// that is the whole list -- eight values, counting -0 with 0
Everything else in the entire language is truthy. That includes a few values that genuinely surprise beginners:
if ("false") console.log("runs"); // a non-empty string is truthy, even "false"!
if ("0") console.log("runs"); // the STRING "0" is truthy (not empty)
if ([]) console.log("runs"); // an empty array is truthy!
if ({}) console.log("runs"); // an empty object is truthy!
if (-1) console.log("runs"); // any non-zero number is truthy
Read those last few again, because they catch people out constantly. The string "false" is truthy -- it is a non-empty string, and the content is irrelevant. The string "0" is truthy for the same reason. And most surprising of all: an empty array and an empty object are truthy. In several other languages an empty collection is falsy, so folks assume if (myArray) tells them whether the array has items. It does not -- it only tells you the array exists. To check for emptiness you look at the length, if (myArray.length), which is a completely different question.
This falsy list is the engine behind a pattern you will see everywhere. Because null, undefined, 0 and "" are all falsy, a plain if (value) is a quick "do I have something meaningful here?" check:
function greet(name) {
if (!name) { // catches "", null, undefined, all falsy
return "Hello, stranger.";
}
return `Hello, ${name}.`;
}
console.log(greet("")); // "Hello, stranger."
console.log(greet("Scipio")); // "Hello, Scipio."
That is convenient, but mind the edge: because 0 is falsy too, if (!count) also fires when count is a legitimate zero. If zero is a valid value you care about, test more precisely (for example if (count === undefined)). We give truthiness, coercion and the == operator a full dedicated episode soon, so I will not drag every rule out here -- but memorize the falsy eight now, because they quietly explain a huge amount of everyday JavaScript behaviour.
A very common beginner habit is deeply nested ifs, one inside another inside another, marching steadily to the right until the real logic is buried ten indents deep. The professional antidote is the guard clause: handle the exceptional, invalid, or early-exit cases first, return out of them immediately, and let the main logic sit flat and un-indented at the bottom.
function describeAge(age) {
if (age < 0) return "invalid"; // guard: bail out on bad input
if (age < 18) return "minor"; // guard
if (age < 65) return "adult";
return "senior"; // the "normal" path, un-nested
}
console.log(describeAge(30)); // "adult"
console.log(describeAge(-5)); // "invalid"
Compare that to the alternative, where each case is nested inside the else of the previous one:
function describeAgeNested(age) {
if (age >= 0) {
if (age >= 18) {
if (age >= 65) {
return "senior";
} else {
return "adult";
}
} else {
return "minor";
}
} else {
return "invalid";
}
}
Both functions do exactly the same thing, but the first one reads like a checklist and the second reads like a Russian doll. The guard-clause style scales beautifully: when a fourth or fifth condition appears, you just add another early return at the top, no re-indenting the entire function. This is a habit genuinely worth building from day one, and once it clicks you will start seeing needless nesting everywhere.
When you are comparing a single value against a list of specific options, a switch can read more cleanly than a long if/else if chain. It compares each case against your value using strict equality (===, no coercion), and runs the first case that matches:
const day = "sat";
switch (day) {
case "sat":
case "sun":
console.log("Weekend!");
break;
case "mon":
console.log("Back to it.");
break;
default:
console.log("A regular day.");
}
// prints "Weekend!"
Two things to note. First, stacking case "sat": directly above case "sun": with no code between them means both labels share the same block -- a clean, readable way to say "either of these". Second, and this is the classic trap that has bitten every JavaScript programmer at least once: each case needs its own break.
Without break, execution "falls through" into the next case and keeps running, regardless of whether that next case matches. This is almost never what you intend, and it is a famous, hard-to-spot source of bugs:
const role = "editor";
switch (role) {
case "editor":
console.log("can edit");
// no break! execution falls through...
case "viewer":
console.log("can view");
break;
default:
console.log("no access");
}
// prints BOTH "can edit" AND "can view"
An "editor" here accidentally also gets the "viewer" message, because the missing break let execution slide straight down into the next block. Now, occasionally intentional fall-through is genuinely useful -- the stacked weekend cases above are exactly that -- but forgetting a break by accident is a real, silent bug. My rule of thumb: always write the break, and if you do want deliberate fall-through, leave an explicit comment saying so, so the next reader (probably future you) knows it was on purpose and not a slip.
Here is something the textbooks often skip. A big switch that maps one value to one result can frequently be replaced by a plain object -- a dispatch table (also called a lookup table). Because objects in JavaScript are key-value maps, you look the answer up directly in stead of walking through cases:
const permissions = {
editor: "can edit",
viewer: "can view",
};
function describeRole(role) {
return permissions[role] ?? "no access"; // ?? supplies the default
}
console.log(describeRole("editor")); // "can edit"
console.log(describeRole("ghost")); // "no access"
That ?? is the nullish coalescing operator from episode 4: if permissions[role] is undefined (no such key), it falls back to "no access". The dispatch-table version has no break to forget, no fall-through to fear, and it is trivial to extend -- adding a role is one new line in the object. It even lets you map to functions, which is a tidy way to pick behaviour, not just a string:
const actions = {
add: (a, b) => a + b,
sub: (a, b) => a - b,
mul: (a, b) => a * b,
};
function calculate(op, a, b) {
const fn = actions[op];
if (!fn) return "unknown operation"; // guard clause, naturally
return fn(a, b);
}
console.log(calculate("add", 4, 5)); // 9
console.log(calculate("mul", 4, 5)); // 20
console.log(calculate("div", 4, 5)); // "unknown operation"
I am not saying switch is bad -- it is perfectly fine, and sometimes the clearest choice, especially when several cases share a block or you want fall-through on purpose. But when you catch yourself writing a long switch whose only job is "this value maps to that value", reach for a dispatch table instead. It is flatter, safer, and more idiomatic JavaScript. Having said that, use whichever makes the intent clearest at the point you are reading it.
Sometimes you do not want to run different code -- you want to choose a value. That is precisely what the ternary operator ? : is for, and unlike if, the ternary is an expression: it evaluates to a value you can assign, return, or drop into a template literal.
const age = 20;
const category = age >= 18 ? "adult" : "minor";
console.log(category); // "adult"
Read it left to right as: condition ? value-if-true : value-if-false. This statement-versus-expression distinction is the whole point. An if is a statement -- it performs an action but does not itself produce a value, so you cannot assign it. The ternary is a value, so it slots right into places an if simply cannot go:
function fee(isMember) {
return `Your fee is ${isMember ? 0 : 25} EUR.`; // inline, inside a template literal
}
console.log(fee(true)); // "Your fee is 0 EUR."
console.log(fee(false)); // "Your fee is 25 EUR."
Ternaries are wonderful for short, clear, two-way value choices. But do not get clever and nest them three deep -- a ? b : c ? d : e ? f : g is a genuine headache to read and an easy place to hide a bug. For a simple either-or value, the ternary is perfect. For anything with real branching logic, or where you need to run statements (not just pick a value), use an if. Matching the tool to the shape of the problem is most of what "clean code" actually means.
Many of you arrived from the Learn Python Series or the Learn Rust Series, so a glance sideways sharpens the picture and shows which of JavaScript's choices are universal and which are its own.
Python has if, elif (its spelling of else if), and else, driven by indentation in stead of braces. The most instructive difference is truthiness: in Python, empty collections are falsy. [], {}, "" and 0 all count as false, which is the exact opposite of JavaScript, where an empty array or object is truthy. So the JavaScript surprise we hammered on above is a real language difference, not a universal law:
# Python: empty collections are FALSY (the opposite of JavaScript)
if []:
print("runs in JS, but NOT in Python")
else:
print("Python takes this branch") # this prints
Python also has no switch for most of its history (a match statement arrived only in 3.10), and no ? : ternary -- instead it writes a conditional expression as value_if_true if condition else value_if_false. Same idea as our ternary, just a different word order.
Rust goes further than all of them: in Rust, if is itself an expression that produces a value, so you rarely need a separate ternary at all. And its match (the big brother of switch) is exhaustive -- the compiler refuses to build if you forget a possible case:
// Rust: `if` is an expression, so this assigns directly
fn main() {
let age = 20;
let category = if age >= 18 { "adult" } else { "minor" };
println!("{}", category); // "adult"
}
That exhaustiveness is a genuine safety win JavaScript's switch does not give you -- forget a case in JS and it silently falls to default or does nothing.
Go keeps the C-style switch but flips the default: in Go, cases auto-break, so you never forget a break, and you opt into fall-through with an explicit fallthrough keyword. That is arguably the sane inversion of JavaScript's design:
// Go: cases break automatically; fall-through is opt-in
switch role := "editor"; role {
case "editor":
fmt.Println("can edit") // no break needed, does NOT fall through
case "viewer":
fmt.Println("can view")
}
C is where JavaScript inherited its switch from, fall-through trap and all -- in C, a missing break also drops into the next case. So JavaScript did not invent that footgun; it copied it faithfully from C in the 1990s, which is exactly why it feels so familiar to C programmers and so alien to Python folks.
The throughline: if/else exists everywhere and works the way you expect. Truthiness rules differ by language (JavaScript's "empty collection is truthy" is a real gotcha). And the switch/match family ranges from JavaScript's error-prone opt-out-of-fall-through, through Go's safer auto-break, up to Rust's compiler-enforced exhaustiveness. Knowing where JavaScript sits on that spectrum is what turns its quirks from "random" into "understandable historical choices" ;-)
Let us close with a small program that leans on several of today's tools at once -- a guard clause, a dispatch table, and a ternary, working together. We will grade a batch of exam results and print a short human-readable line for each:
function letterGrade(score) {
if (score < 0 || score > 100) return "invalid"; // guard clause first
if (score >= 90) return "A";
if (score >= 75) return "B";
if (score >= 60) return "C";
return "F";
}
const messages = {
A: "excellent",
B: "solid",
C: "passing",
F: "needs work",
invalid: "check the data",
};
function report(name, score) {
const grade = letterGrade(score);
const note = messages[grade]; // dispatch table lookup
const passed = grade !== "F" && grade !== "invalid" ? "PASS" : "FAIL"; // ternary
return `${name}: ${grade} (${note}) - ${passed}`;
}
console.log(report("Ann", 95)); // "Ann: A (excellent) - PASS"
console.log(report("Bo", 61)); // "Bo: C (passing) - PASS"
console.log(report("Cy", 40)); // "Cy: F (needs work) - FAIL"
console.log(report("Di", 150)); // "Di: invalid (check the data) - FAIL"
Trace it through. letterGrade uses a guard clause to reject nonsense scores up front, then a flat chain of early returns to pick the letter -- no nesting at all. report then looks the friendly note up in the messages object (a dispatch table, no switch in sight), and a single ternary decides PASS or FAIL from the grade. Each piece is small, does one job, and reads top to bottom. That is the whole spirit of good control flow: not clever, just clear.
grade(score) that returns "A", "B", "C", or "F" using an if/else if chain, matching the boundaries from the episode (90, 75, 60). Test it with 95, 80, 61, and 40, and add a comment explaining why the order of the conditions matters.if (""), if ("0"), if ([]), if (0), if (null), if ({}). Write down which are truthy and which are falsy, and note the two results that most surprised you and why.if/else as a single ternary assigned to a variable, then in a comment explain when a ternary is clearer than an if and when it is not: let label; if (n % 2 === 0) { label = "even"; } else { label = "odd"; }.if / else if / else runs the first branch whose condition is truthy, so order your conditions from most specific to least; a chain runs at most one branch, while separate ifs can each run.false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy -- including non-empty strings like "false", and (the big surprise) empty arrays and empty objects.return for invalid or edge cases -- keep code flat and readable in stead of deeply nested.switch compares one value against many cases with ===; stack cases to share a block, but always break to avoid unintended fall-through.?? for the default) frequently beats switch for value-to-value mapping: flatter, safer, and easy to extend.condition ? a : b is an expression that produces a value, ideal for short inline choices; do not nest it three deep.if is an expression and its match is exhaustive, and Go's switch auto-breaks -- JavaScript's fall-through trap came straight from C.Next episode we move from making one decision to repeating work: loops. The classic for, the while, and the modern for...of and for...in, plus exactly when to reach for each one.