When a Chain of else if Becomes a Switch
A switch statement compares one value against a list of possible values and runs the block that matches. It does the same job as a chain of else if comparisons, and it exists because that chain becomes tiring to read once every branch is comparing the same variable over and over.
The shape is a switch (value) header, then a series of case x: labels, then an optional default: for anything that did not match. default does not technically have to be last, but everyone reading your code will expect it there, so put it there.
Use switch when you are testing one value against several fixed, known possibilities — an order status, a menu choice, an HTTP status code, a day of the week. Use if/else if when the branches test different things, or involve ranges and combinations. The distinction is not merely stylistic: a reader who sees switch (status) knows immediately that every branch below concerns status and nothing else, and that is genuine information they get for free.
One structural detail catches people out. case labels are not blocks, so a let or const declared inside one case is visible to every other case in the same switch. Two cases that each declare const message is a SyntaxError, not a scoping win. When a case body needs its own variables, wrap that body in its own pair of braces.
const status = 'shipped';
switch (status) {
case 'pending':
console.log('Order received');
break;
case 'shipped':
console.log('On the way');
break;
case 'delivered':
console.log('Delivered');
break;
default:
console.log('Unknown status');
}
// 'On the way'
// A case that needs its own variables gets its own braces
switch (status) {
case 'shipped': {
const message = 'Out for delivery';
console.log(message);
break;
}
case 'delivered': {
const message = 'Signed for'; // fine - a separate block
console.log(message);
break;
}
} - Without the extra braces, the second
const messageabove isSyntaxError: Identifier 'message' has already been declared— and the error points at a line that looks perfectly innocent, because the two declarations are in different cases.
break, and the Fall-Through That Bites
This is the one thing about switch that nearly everyone gets wrong at least once. A case label is not a self-contained branch — it is an entry point. Once execution enters at a matching case, it carries straight on down through every case below it until it meets a break, a return, or the end of the switch. The labels below do not have to match; they are not even looked at.
So forgetting one break does not produce an error message. It produces extra output from cases that never matched, which is a far more confusing symptom than a crash — the code looks correct, the matching case clearly worked, and yet something else happened too. If a switch is doing two things when you expected one, a missing break is the first place to look and it is usually the answer.
return works just as well as break, and in a function it is usually better. When a function's only job is to turn one value into another, returning directly from each case removes every break and removes the entire class of bug along with them. There is nothing left to forget.
The same applies inside a loop with one caution: break inside a switch that is inside a loop breaks out of the switch, not the loop. If you meant to leave the loop you need a labelled break, or — far more readable — restructure so the switch sits inside a function that returns.
const day = 'Tuesday';
// Missing breaks: execution falls straight through the labels below
switch (day) {
case 'Tuesday':
console.log('Tuesday');
case 'Wednesday':
console.log('Wednesday'); // also runs - it never matched anything
case 'Thursday':
console.log('Thursday'); // and so does this
}
// prints Tuesday, Wednesday, Thursday
// With break, only the matching case runs
switch (day) {
case 'Tuesday':
console.log('Tuesday');
break;
case 'Wednesday':
console.log('Wednesday');
break;
}
// prints Tuesday
// return makes break unnecessary
function shortDay(name) {
switch (name) {
case 'Monday': return 'Mon';
case 'Tuesday': return 'Tue';
case 'Wednesday': return 'Wed';
default: return '???';
}
}
console.log(shortDay('Tuesday')); // 'Tue' - Fall-through is deliberate in the language design, not an oversight, which is why no engine warns you about it. A linter rule such as
no-fallthroughis the only thing that will flag it automatically, and it is worth turning on.
Deliberate Fall-Through: Grouping Cases
Fall-through is not only a hazard — it is also the intended way to make several values share a single block. Stack the labels one after another with no code between them, and every one of them enters the same code below.
This is the cleanest way to say "any of these" in a switch, and it reads better than a long || chain inside an if. A weekend check, a set of HTTP status codes that all mean success, a group of file extensions that all count as images — all of them are naturally expressed as stacked labels.
When you fall through deliberately in the middle of a case body — running some code and then continuing into the next case — write a comment saying so. Every reviewer who sees a case body without a break assumes a bug, and a one-line comment is what tells them otherwise. Several standard linter configurations accept exactly this and nothing else.
Grouped labels also make the default case more meaningful. Once the expected values are listed explicitly, default genuinely means "something we did not plan for", which is a good place to log a warning rather than silently doing nothing. Silence in a default is how unexpected data gets ignored for months.
const day = 'Saturday';
switch (day) {
case 'Saturday':
case 'Sunday':
console.log('Weekend');
break;
case 'Monday':
case 'Tuesday':
case 'Wednesday':
case 'Thursday':
case 'Friday':
console.log('Weekday');
break;
default:
console.warn('Not a recognised day:', day);
}
// 'Weekend'
// Grouping status codes
function describe(code) {
switch (code) {
case 200:
case 201:
case 204:
return 'Success';
case 400:
case 422:
return 'Bad request';
case 401:
case 403:
return 'Not allowed';
case 500:
return 'Server error';
default:
return 'Unhandled status: ' + code;
}
}
console.log(describe(201)); // 'Success'
console.log(describe(418)); // 'Unhandled status: 418' - Stacked labels cost nothing at runtime — the engine is not running an empty case for each one, it is simply entering the block at the first label that matches.
switch Compares Strictly
switch compares using the same rule as ===. There is no type conversion whatsoever, and this is the second most common surprise after fall-through.
The consequence shows up the very first time you use a switch on real input, because form fields and URL parameters both hand you strings. A switch (input.value) where input.value is '2' will never match case 2:, and there is no error — control simply lands in default, or nowhere at all if you did not write one. Convert once at the top with Number(input.value), or write every case as a string. Choose one and stay consistent; mixing the two inside a single switch is how this bug survives a review.
One more consequence follows from strict comparison: NaN matches nothing, including case NaN:, because NaN === NaN is false. If a numeric switch has to cope with input that might not be a number, check with Number.isNaN before the switch rather than trying to catch it inside.
The same strictness applies to objects and arrays, for the reason Lesson 5 gave: comparison is by identity. A case holding an object literal can never match, because the object in the case is a different object from the one you are switching on, however identical they look.
const choice = '2'; // exactly as it arrives from an input box
switch (choice) {
case 1:
console.log('one');
break;
case 2:
console.log('two'); // never runs: '2' !== 2
break;
default:
console.log('no match'); // this is what prints
}
// Convert once, then the numeric cases work
switch (Number(choice)) {
case 2:
console.log('two'); // now it runs
break;
}
// NaN matches nothing at all, not even itself
switch (Number('abc')) {
case NaN:
console.log('never runs');
break;
default:
console.log('landed in default');
}
// So check for it before the switch, not inside it
const n = Number('abc');
if (Number.isNaN(n)) {
console.log('Please enter a number');
} switch (undefined)will not matchcase null:either, even thoughnull == undefinedis true. Loose equality never applies inside a switch.
The switch(true) Pattern for Ranges
A plain switch tests for equality, so it cannot express a range on its own. There is a well-known idiom for that: switch (true), where each case holds a complete boolean expression. Because every case is compared strictly against true, the first expression that evaluates to true is the one that runs.
This gives you an if/else if ladder in a shape some people find easier to scan, especially when every branch is testing the same variable against a different threshold — the thresholds line up vertically and a missing band is easy to spot. It is a legitimate pattern and you will meet it in real codebases, so it is worth recognising even if you never write it.
Be honest about the trade-off, though. It works by using switch for something switches were not designed for, and a reader who has not seen it before has to stop and reason about why the header says true. For a grade ladder, a plain chain of if statements with early returns is clearer to more people, and there is no prize for cleverness in code that someone else will maintain.
The genuine deciding question is what the branches contain. If they are thresholds on one number, either form is fine. If they are unrelated conditions on different variables, switch (true) is actively misleading, because the header promises that everything below concerns one value and then it does not.
const marks = 82;
switch (true) {
case marks >= 75:
console.log('Distinction');
break;
case marks >= 60:
console.log('First class');
break;
case marks >= 40:
console.log('Pass');
break;
default:
console.log('Fail');
}
// 'Distinction'
// The same logic, readable by more people
function grade(m) {
if (m >= 75) return 'Distinction';
if (m >= 60) return 'First class';
if (m >= 40) return 'Pass';
return 'Fail';
}
console.log(grade(82)); // 'Distinction' - Order matters in
switch (true)exactly as it does in anifladder. Putcase marks >= 40first and every student above 40 is a Pass, because the first true case wins and the rest are never reached.
switch versus a Lookup Object
When every case does nothing except map one value to another value, a plain object is usually the better tool. You write the pairs once, the intent is obvious at a glance, and the table is data — so it can be moved to a configuration file, loaded from a server, or built at runtime. A switch can never be any of those things.
The thing to be careful about is the fallback. lookup[key] returns undefined for a key you never defined, so pair it with ?? to supply a default. Use ?? rather than ||, so that a legitimate 0 or empty string sitting in your table is not silently replaced by the fallback.
There is also a safety point that matters as soon as the key comes from a user. An object literal inherits properties from Object.prototype, so lookup['toString'] returns a function rather than undefined and sails past a naive check. Guard with Object.hasOwn(lookup, key), or build the table with new Map(), which has no inherited keys at all and accepts any type of key.
Keep the switch when the cases genuinely do different work rather than producing different values — when one branch calls an API, another opens a dialogue, and a third writes a log line. The short version: statements belong in a switch, values belong in a table.
// A switch whose only job is mapping values
function shortDay(name) {
switch (name) {
case 'Monday': return 'Mon';
case 'Tuesday': return 'Tue';
case 'Wednesday': return 'Wed';
default: return '???';
}
}
// The same thing expressed as data
const SHORT_DAY = {
Monday: 'Mon',
Tuesday: 'Tue',
Wednesday: 'Wed'
};
function shortDay2(name) {
return Object.hasOwn(SHORT_DAY, name) ? SHORT_DAY[name] : '???';
}
console.log(shortDay2('Tuesday')); // 'Tue'
console.log(shortDay2('Funday')); // '???'
console.log(shortDay2('toString')); // '???' - the guard is doing real work
// Without a guard, inherited properties leak through
console.log(typeof SHORT_DAY['toString']); // 'function', not 'undefined'
// A Map has no inherited keys and takes keys of any type
const byCode = new Map([[200, 'Success'], [404, 'Not found']]);
console.log(byCode.get(404) ?? 'Unknown'); // 'Not found'
console.log(byCode.get(999) ?? 'Unknown'); // 'Unknown' - One value against several fixed possibilities —
switch - Ranges, combinations, or a different variable in each branch —
if / else if - Every branch just returns a value — a lookup object or a
Map - Every case must end in
breakorreturn - Comment any fall-through you actually intended
switchcompares with===— convert form input before switching on it
- Naming a lookup table in
UPPER_SNAKE_CASEand declaring it outside your function is worth the habit. Defined inside the function, the object is rebuilt on every single call for no benefit.
