Every program is a sequence of decisions. JavaScript gives you two ways to make them: if/else for open-ended branching, and switch for matching one value against a known list. Both have rules that are not obvious until you hit them — particularly the way switch falls through, and the way an else if ladder grows deeper than anyone intended.
What is the basic shape of an if/else?
Three keywords, in order: if with a condition, an optional else if with another condition, and an optional else for everything left over.
function shippingCost(weightKg, express) {
if (express) {
return 120;
} else if (weightKg > 10) {
return 60;
} else {
return 30;
}
}
console.log(shippingCost(5, false)); // 30
console.log(shippingCost(12, false)); // 60
console.log(shippingCost(5, true)); // 120
Only the first matching branch runs. Once a condition is true, its block executes and the rest of the chain is skipped entirely — which is why an else if placed before a more specific test will shadow it.
One structural detail is worth knowing early: a let or const declared inside a block belongs to that block and cannot be read outside it.
function pickSize(quantity) {
if (quantity > 10) {
const discount = 0.2;
return `bulk, ${discount * 100}% off`;
}
return "standard";
}
console.log(pickSize(12)); // "bulk, 20% off"
console.log(pickSize(2)); // "standard"
Why are guard clauses better than an else-if ladder?
An else if chain nests the happy path at the bottom of an indentation staircase. Every additional rule pushes the main case further down and further right. Guard clauses invert that: each rule states its condition and leaves immediately, so the remaining path stays at the left margin.
// Ladder: the interesting case is at the bottom of a pyramid.
function priceLadder(user, cart) {
if (user) {
if (cart.length > 0) {
if (user.member) {
return cart.subtotal * 0.9;
} else {
return cart.subtotal;
}
}
}
return 0;
}
// Guards: each rule is a flat line.
function priceGuards(user, cart) {
if (!user) return 0;
if (cart.length === 0) return 0;
return user.member ? cart.subtotal * 0.9 : cart.subtotal;
}
console.log(priceLadder({ member: true }, { length: 1, subtotal: 100 })); // 90
console.log(priceGuards({ member: true }, { length: 1, subtotal: 100 })); // 90
Both return 90. The second version has no nesting at all, and each line reads as a complete rule. This is the single most effective readability improvement available to a beginner, and it costs nothing but the habit of writing if (...) return ...; on one line.
How does switch work?
switch takes one expression and compares it against a list of case values. The first match wins, and break stops the engine from continuing past it.
function planLabel(plan) {
switch (plan) {
case "free":
return "Up to 5 projects";
case "pro":
return "Unlimited projects";
case "team":
return "Unlimited projects plus 10 seats";
default:
return "Unknown plan";
}
}
console.log(planLabel("pro")); // "Unlimited projects"
console.log(planLabel("team")); // "Unlimited projects plus 10 seats"
console.log(planLabel("gold")); // "Unknown plan"
Note that each case ends with return, which exits the function before any break could matter. That is a perfectly good pattern: when every branch returns, break becomes redundant.
The default branch handles everything that matched nothing. It is optional, but it is worth including whenever the input comes from outside your program — a switch on a plan name should have a default, because plans get renamed and a missing case should be visible rather than silent.
What is fallthrough, and when is it useful?
If you leave out break, the engine keeps running into the next case. That is usually a bug and occasionally a feature.
// Deliberate fallthrough: two cases share one body.
function classify(code) {
let label = "";
switch (code) {
case 200:
case 201:
label = "success";
break;
case 404:
label = "not found";
break;
default:
label = "other";
}
return label;
}
console.log(classify(200)); // "success"
console.log(classify(404)); // "not found"
console.log(classify(500)); // "other"
The stacked case 200: and case 201: with nothing between them mean "either of these values runs this block". That is the legitimate use: several inputs, one outcome.
The illegitimate use is an accidental fallthrough, where a missing break lets one case run the next case's code too. When you want to see the difference:
function auditLog(code) {
const entries = [];
switch (code) {
case 400:
entries.push("client error");
case 401:
entries.push("needs auth");
break;
case 500:
entries.push("server error");
break;
}
return entries;
}
console.log(auditLog(400)); // [ "client error", "needs auth" ]
console.log(auditLog(500)); // [ "server error" ]
For 400 the function records two entries, because the missing break let the 400 case fall through into the 401 case. For 500 it records one. If that was not intended, the fix is one break. Many style guides also require a // falls through comment wherever a fallthrough is deliberate, so the next reader knows it was a decision.
Why does switch use strict comparison?
Because the comparison inside a switch is always ===. The number 2 does not match the case "2", and the string "2" does not match the case 2.
console.log(2 === "2"); // false
switch (2) {
case "2":
console.log("matched the string");
break;
default:
console.log("no match - switch uses ===");
}
// no match - switch uses ===
switch ("free") {
case "free":
console.log("free plan");
break;
default:
console.log("other plan");
}
// free plan
This is the reason a switch on a value from a form or a URL will silently fall to default when the type is not what you expected. The fix is the same as everywhere else: normalise the input before you switch on it, with String(value) or Number(value).
Which should you use?
| Situation | Reach for |
|---|---|
| A range of conditions, or a condition that is not a simple equality test | if/else with guard clauses |
| One value matched against a known list | switch |
| A single yes-or-no choice that produces a value | A ternary |
| More than about three branches of equal weight | switch, or a lookup object |
- Only the first matching branch runs. Order your conditions from most specific to least.
- A
letinside a block stays inside that block. - Prefer guard clauses with early returns over deep
else ifladders. switchcompares with===, so2and"2"are different cases.- A missing
breakcauses fallthrough. Use stacked cases deliberately, and comment them. - Always give a
switchover external input adefaultbranch.
Try it yourself: write a describeStatus(code) function that maps HTTP status codes to a message, using switch with stacked cases for the success range (200 to 299) and a default for anything else. Then deliberately remove one break and run it against a code in that range to watch the fallthrough happen. Put the break back, and finish by converting the whole thing to a lookup object — you will see when that reads better.