JavaScript Switch
switch is JavaScript’s pattern for dispatching on a value across many possible cases. It’s clean for command-style dispatchers and state machines, but it has two big footguns: fall-through and strict equality only matching. This chapter covers the syntax, the gotchas, and the lookup-object pattern that’s often a better choice.
Basic Syntax
const role = "admin";
switch (role) {
case "admin":
console.log("Full access");
break;
case "editor":
console.log("Can edit");
break;
case "viewer":
console.log("Read only");
break;
default:
console.log("No access");
}
// Output: Full access
The expression in switch (...) is evaluated once and compared with each case value using strict equality (===) — the same rules as === covered in chapter 24.
The Fall-Through Trap
Without break, execution falls through to the next case:
const day = "Sat";
switch (day) {
case "Sat":
console.log("weekend");
// ⚠ no break
case "Sun":
console.log("relax");
break;
default:
console.log("weekday");
}
// Output:
// weekend
// relax
This is sometimes intentional — but more often a bug. ESLint’s no-fallthrough rule flags it.
Warning: Always end every case with break, return, or throw. If you genuinely want fall-through, put a comment // fall through so reviewers (and future you) know it’s deliberate.
Intentional Fall-Through — Grouping Cases
The one acceptable use of fall-through: grouping cases that share the same body.
function isWeekend(day) {
switch (day) {
case "Sat":
case "Sun":
return true;
default:
return false;
}
}
isWeekend("Sat"); // true
isWeekend("Mon"); // false
Stacking cases with no body in between is the idiomatic way to say “any of these”.
Default — Position Doesn’t Matter (But Convention Does)
switch (x) {
default:
console.log("other");
break;
case 1:
console.log("one");
break;
}
Legal, but unreadable. Convention: put default last.
If default is missing and nothing matches, the switch is a no-op:
switch (1) {
case 2: console.log("two"); break;
}
// (no output)
Returning From Switch
When every case ends in return, you don’t need break:
function describe(role) {
switch (role) {
case "admin": return "Administrator";
case "editor": return "Editor";
case "viewer": return "Viewer";
default: return "Unknown";
}
}
This is the cleanest form for a function whose whole job is dispatch. No break, no fall-through risk.
Strict Equality — Beware of Type
Cases match with ===, never ==. This bites when comparing input that’s a string vs a number:
const status = "200"; // string — maybe from an API or URL
switch (status) {
case 200: // number 200 — never matches!
console.log("OK");
break;
case "200": // matches
console.log("OK as string");
break;
}
// Output: OK as string
switch (NaN) {
case NaN: // ❌ never matches — NaN !== NaN
console.log("nan");
break;
}
Switching on Ranges — Use true Trick (or Don’t)
switch matches values, not predicates. To dispatch on ranges you need a workaround:
function grade(score) {
switch (true) { // expression is `true`
case score >= 90: return "A";
case score >= 80: return "B";
case score >= 70: return "C";
default: return "F";
}
}
Each case is then a boolean expression: the first one that equals true matches. It works but it’s clever — many style guides ban it. An if/else if chain is usually clearer:
function grade(score) {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
return "F";
}
Block Scoping Inside Cases
By default, all cases share one lexical scope. Declaring let/const in two cases collides:
switch (x) {
case 1:
const msg = "one"; // ❌ SyntaxError if any other case also declares msg
break;
case 2:
const msg = "two";
break;
}
Wrap each case body in braces to give it its own scope:
switch (x) {
case 1: {
const msg = "one";
console.log(msg);
break;
}
case 2: {
const msg = "two";
console.log(msg);
break;
}
}
Tip: Get into the habit of always wrapping case bodies in {} if they declare variables. It costs you two characters and saves a category of bug.
When switch Is Better — and When It Isn’t
| Situation | Use |
|---|---|
| Dispatch on a single discrete value to one of N branches | switch or lookup object |
| Branches all just return a value | lookup object is clearer |
| Branches do non-trivial work | switch (with return form) |
| Conditions are ranges or compound predicates | if/else if |
| Polymorphic behaviour by type | classes / methods |
The Lookup Object Alternative
For data-style dispatch, an object is shorter, declarative, and has no fall-through risk:
const ROLE_LABEL = {
admin: "Administrator",
editor: "Editor",
viewer: "Viewer",
};
function describe(role) {
return ROLE_LABEL[role] ?? "Unknown";
}
When the branches do work (not just return data), use a function map:
const HANDLERS = {
click: e => console.log("clicked at", e.x, e.y),
keydown: e => console.log("key", e.key),
scroll: e => console.log("scrolled"),
};
function dispatch(eventType, event) {
const handler = HANDLERS[eventType] ?? (() => console.log("unknown event"));
handler(event);
}
A lookup object is also easier to extend at runtime — just add a key.
A Realistic Example — Reducer Style
A small state machine with a switch:
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM": {
const items = [...state.items, action.item];
return { ...state, items, total: state.total + action.item.price };
}
case "REMOVE_ITEM": {
const items = state.items.filter(i => i.id !== action.id);
const removed = state.items.find(i => i.id === action.id);
const total = removed ? state.total - removed.price : state.total;
return { ...state, items, total };
}
case "CLEAR_CART":
return { ...state, items: [], total: 0 };
default:
return state; // unknown action → no change
}
}
// Usage
let state = { items: [], total: 0 };
state = cartReducer(state, { type: "ADD_ITEM", item: { id: 1, price: 10 } });
state = cartReducer(state, { type: "ADD_ITEM", item: { id: 2, price: 5 } });
console.log(state.total); // 15
Each case has its own brace-scoped block, returns immediately, and a sensible default keeps unknown actions safe.
Common Mistakes
// ❌ Forgetting break
switch (x) {
case 1: console.log("one"); // falls through!
case 2: console.log("two"); break;
}
// ❌ Type mismatch with strict equality
switch ("1") {
case 1: /* never */ break;
}
// ❌ Variable name collision across cases
switch (x) {
case 1: const v = "a"; break;
case 2: const v = "b"; break; // SyntaxError
}
// ❌ Missing default — silent no-op for unknown values
switch (status) {
case "ok": done(); break;
case "err": fail(); break;
}
// what about other statuses?
Best Practices Checklist
- ✅ Always include
break,return, orthrowat the end of every case - ✅ Always include a
default(even if it justreturns the unchanged state) - ✅ Wrap case bodies in
{}when declaring variables - ✅ Convert switches that just map values to a lookup object
- ✅ Coerce types before switching (
switch (Number(status))) when input is mixed - ❌ Don’t use
switch (true)for ranges — useif/else if - ❌ Don’t fall through implicitly without a
// fall throughcomment - ❌ Don’t reach for
switchfor two cases —if/elseis fine
What’s Next?
You’ve covered branching. Time for the other half of control flow: looping. The classic for, the modern for…of, and the array iteration helpers all working in concert.