JavaScript Switch Statement: A Visual Guide
The checkout status was "paid", but the notification code also sent the parcel message. One missing break let execution slide into the next case, and every individual line still looked reasonable.
A JavaScript switch statement evaluates one expression, selects the first strictly equal case in source order, and executes from that label until control leaves the switch or reaches its end.
What Is a JavaScript Switch Statement?
A switch statement is multi-branch control flow. You give it one controlling expression, then attach several possible entry points called case clauses.
The basic form has four parts:
switch (expression)evaluates the value used for matching.case value:marks a possible entry point.breakexits the switch.default:provides an entry point when no case matches.
Start with a complete status formatter:
const status = "paid";
let message;
switch (status) {
case "draft":
message = "Order is still being edited";
break;
case "paid":
message = "Payment received";
break;
case "shipped":
message = "Parcel is on the way";
break;
default:
message = "Unknown order status";
}
console.log(message);
Payment received
JavaScript evaluates status, finds the first case whose expression matches "paid", and begins executing there. The assignment stores "Payment received", then break moves control to the first statement after the switch.
default is optional. Without it, a switch with no matching case does nothing.
This makes switch useful when several branches all answer the same question: which exact value does this expression hold? The dedicated switch statement lesson covers the core syntax alongside other Core JavaScript control-flow tools.
How Switch Matching Actually Works
A switch has a matching phase and an execution phase. Keeping them separate explains strict matching, source order, side effects in case expressions, and fall-through.
During the matching phase, JavaScript follows this order:
- Evaluate the controlling expression once.
- Evaluate case expressions from top to bottom.
- Compare each result with the controlling value using strict-equality semantics.
- Select the first match and stop evaluating later case expressions.
- Begin the execution phase at the selected label.
This annotated trace exposes both phases:
1. Evaluate `status` → `"paid"`
↓
2. Evaluate the draft case expression → `"draft"`; compare → no match
↓
3. Evaluate the paid case expression → `"paid"`; compare → match
↓
4. Select the paid label; skip the shipped case expression
↓
5. Run the paid body → fall through → run the shipped body → `break`
The code and console output show the same path:
function caseValue(label, value) {
console.log(`matching: evaluate ${label}`);
return value;
}
const status = "paid";
switch (status) {
case caseValue("draft case", "draft"):
console.log("execution: draft body");
break;
case caseValue("paid case", "paid"):
console.log("execution: paid body");
case caseValue("shipped case", "shipped"):
console.log("execution: shipped body");
break;
default:
console.log("execution: default body");
}
matching: evaluate draft case
matching: evaluate paid case
execution: paid body
execution: shipped body
The first two lines belong to matching. Once "paid" matches, JavaScript does not evaluate the "shipped" case expression.
The final two lines belong to execution. The missing break after the paid body lets execution continue into the statements under the shipped label, but that label is now an entry marker, not a condition JavaScript checks again.
Fall-through executes later statements; it does not restart matching.
Matching performs no type conversion. The number 1 and the string "1" may describe the same visible digit, but they are different values:
const choice = "1";
switch (choice) {
case 1:
console.log("number");
break;
case "1":
console.log("string");
break;
}
string
Only the string case matches. The same strict distinction appears throughout JavaScript comparisons, though switch uses it to choose an entry point rather than produce a Boolean result.
Case expressions can be expressions rather than literals. That does not turn the statement into a lookup table: JavaScript still evaluates them in source order until it finds the first match.
Break, Default, and Fall-Through
break is the usual way out of a switch, but it is not the only one. return exits the surrounding function, and continue can move to the next iteration when the switch sits inside a loop.
This function returns directly from every branch:
function shippingLabel(method) {
switch (method) {
case "pickup":
return "Collect from store";
case "courier":
return "Deliver to address";
default:
return "Choose a shipping method";
}
}
console.log(shippingLabel("courier"));
Deliver to address
No break is needed because return has already left the function.
Inside a loop, continue can skip the rest of the current iteration:
const tasks = ["ready", "paused", "ready"];
for (const task of tasks) {
switch (task) {
case "paused":
continue;
case "ready":
console.log("run task");
break;
}
console.log("iteration finished");
}
run task
iteration finished
run task
iteration finished
For "paused", continue belongs to the surrounding for loop. It starts the next iteration, so "iteration finished" is not printed.
Several empty labels can share one body. This is grouping via intentional empty-case fall-through:
function isWeekend(day) {
switch (day) {
case "Saturday":
case "Sunday":
return true;
default:
return false;
}
}
console.log(isWeekend("Sunday"));
console.log(isWeekend("Tuesday"));
true
false
Both weekend labels enter the same return true body. There are no statements between them to run accidentally.
The bug-prone form of fall-through begins when a non-empty clause executes statements and then reaches another clause without terminating. Say a delivery fee starts at zero and each case adds its fee:
const zone = "local";
let fee = 0;
switch (zone) {
case "local":
fee += 5;
case "remote":
fee += 15;
break;
default:
fee = 25;
}
console.log(fee);
20
The intended local fee was 5, but the result is a plausible 20. The "local" case adds 5, then execution falls through and adds the remote fee too.
Here is the complete corrected replacement:
const zone = "local";
let fee = 0;
switch (zone) {
case "local":
fee += 5;
break;
case "remote":
fee += 15;
break;
default:
fee = 25;
}
console.log(fee);
5
Intentional fall-through does have uses, but label it where it occurs:
const access = "owner";
const permissions = [];
switch (access) {
case "owner":
permissions.push("delete");
// falls through
case "editor":
permissions.push("write");
// falls through
case "viewer":
permissions.push("read");
break;
}
console.log(permissions.join(", "));
delete, write, read
The owner receives each capability accumulated by the later clauses. The comments state that the missing termination is deliberate.
default may appear anywhere, though a switch can contain only one. If it appears before later cases and no case matches, execution starts at default and can fall through from there. Putting it last makes that behavior easier to see.
Four Switch Traps That Cause Real Bugs
Switch bugs tend to come from values that look alike, values strict equality cannot match, references that are not identical, or declarations whose scope is wider than the indentation suggests.
Mismatched types
User input often arrives as text. A string does not match a numeric case:
const floor = "2";
switch (floor) {
case 2:
console.log("second floor");
break;
default:
console.log("no numeric match");
}
no numeric match
Convert at the boundary when the program requires a number, or write string cases when text is the intended value. Type Conversions explains the conversion step in detail.
NaN and signed zero
A case NaN clause cannot match because strict equality treats NaN as unequal to every value, including itself. By contrast, +0 and -0 are equal under strict equality, so separate zero cases cannot distinguish them.
This compact laboratory shows both results:
function classifyNumber(value) {
switch (value) {
case NaN:
return "NaN case";
case 0:
return "zero case";
default:
return Number.isNaN(value) ? "detected NaN" : "other";
}
}
console.log(classifyNumber(NaN));
console.log(classifyNumber(+0));
console.log(classifyNumber(-0));
detected NaN
zero case
zero case
Detect NaN before the switch or inside default. Use another test, such as Object.is, when signed zero genuinely needs separate handling. The Numbers lesson covers these numeric edge cases.
Object reference identity
Two object literals with the same properties are still two different references:
const savedFilter = { status: "paid" };
const activeFilter = { status: "paid" };
switch (activeFilter) {
case savedFilter:
console.log("same filter");
break;
default:
console.log("different object");
}
different object
Matching an object works only when the switch value and case expression produce the same object reference. Dispatch on a stable property such as activeFilter.status when the branch depends on the object’s data.
Shared lexical scope
Case clauses belong to one switch block. They do not each create a separate lexical scope, so repeating a const name in two unbraced cases causes a syntax error before the program runs.
Braces give each case its own block:
const command = "save";
switch (command) {
case "save": {
const message = "Document saved";
console.log(message);
break;
}
case "publish": {
const message = "Document published";
console.log(message);
break;
}
default: {
console.log("Unknown command");
}
}
Document saved
Use the same braces around case-local let, function, and class declarations. The braces make the real scope match the visual structure.
Useful Switch Patterns
The cleanest switch often sits inside a function and returns one result. Every branch terminates, and the caller does not need a mutable variable outside the statement:
function formatPriority(priority) {
switch (priority) {
case "low":
return "Can wait";
case "normal":
case "high":
return "Add to queue";
case "urgent":
return "Handle now";
default:
return "Invalid priority";
}
}
console.log(formatPriority("high"));
Add to queue
Grouping handles values that share behavior. A status or discriminant property handles objects whose shape selects the operation:
function reduceOrder(order) {
switch (order.status) {
case "draft":
return `Edit order ${order.id}`;
case "paid":
return `Pack order ${order.id}`;
case "shipped":
return `Track order ${order.id}`;
default:
return `Review order ${order.id}`;
}
}
console.log(reduceOrder({ id: 42, status: "shipped" }));
Track order 42
Here status is the discriminant: one property whose value identifies which branch applies. This shape also works well when each case delegates to a separate function, a small form of the Strategy pattern.
switch (true) changes the question. Each case expression produces a Boolean, and the first expression equal to true wins:
function ticketBand(age) {
switch (true) {
case age < 5:
return "free";
case age < 18:
return "child";
case age >= 65:
return "senior";
default:
return "adult";
}
}
console.log(ticketBand(12));
child
Order matters because age < 5 is also covered by age < 18. This is ordered Boolean-condition matching, not ordinary dispatch on one domain value. Use it when the ordered case layout reads better than the corresponding if...else; it is not the default replacement for if...else. The individual conditions follow the rules in Logical operators.
Switch vs If, Object Lookups, and Map
Choose the construct by the kind of decision the code makes.
| Construct | Use it when | Example question |
|---|---|---|
switch | Several exact branches share one controlling value and each branch performs control flow | Which order status is this? |
if...else | Branches use ranges, compound tests, or unrelated Boolean conditions | Is the account locked, or is the balance too low? |
| Object lookup | Simple string keys select data with no branch-specific control flow | Which label belongs to this code? |
Map | Data-driven keys may have arbitrary value types | Which handler belongs to this object reference? |
A lookup object is shorter when the entire job is retrieving data:
const labels = {
draft: "Still editing",
paid: "Ready to pack",
shipped: "On the way",
};
const status = "paid";
console.log(Object.hasOwn(labels, status) ? labels[status] : "Unknown status");
Ready to pack
A switch earns its space when branches validate, return, continue, call different operations, or intentionally share execution. Use if...else when forcing several unrelated conditions into case expressions hides the decision.
Do not choose between them by assuming one is inherently faster. Engine behavior and the actual workload determine performance. Choose the form that states the selection rule accurately.
Writing Maintainable Switch Statements
A maintainable switch makes each entry point, scope, and exit visible. Apply the same rules whether the statement has three cases or thirty:
- Wrap a case body in braces when it declares
const,let, a function, or a class. - End each active case explicitly with
break,return,throw, or an applicablecontinue. - Mark every deliberate fall-through with a nearby comment.
- Handle unexpected values in
default, unless ignoring them is a deliberate part of the function’s contract. - Keep the controlling expression focused on one named value or discriminant.
- Move large case bodies into named functions before the switch becomes a wall of implementation details.
ESLint’s no-case-declarations rule reports lexical declarations placed directly in case clauses without braces. In ESLint 10.9.1, the recommended @eslint/js configuration enables it, so the braced style works with the rule and makes the scope accurate on the page.
ESLint’s no-fallthrough rule reports a case that can run into the next one. In ESLint 10.9.1, the recommended @eslint/js configuration enables it, and the rule recognizes comments marking intentional fall-through. These two rules turn the most common visual ambiguities into automated checks. Broader naming and layout choices belong with the site’s Coding style guidance.
Optional TypeScript exhaustiveness
TypeScript can check a switch over a discriminated union by assigning the remaining value to never in default.
Here is the pattern:
type Order =
| { status: "draft"; id: number }
| { status: "paid"; id: number }
| { status: "shipped"; id: number };
function nextAction(order: Order): string {
switch (order.status) {
case "draft": {
return `Edit order ${order.id}`;
}
case "paid": {
return `Pack order ${order.id}`;
}
case "shipped": {
return `Track order ${order.id}`;
}
default: {
const unhandled: never = order;
return unhandled;
}
}
}
After the three known statuses have been handled, TypeScript narrows order to never. If the union later gains another status and the switch does not gain a matching case, the assignment reports that the switch is no longer exhaustive. This example was checked with TypeScript 7.0.2; the TypeScript handbook documents this never-based exhaustiveness check.
never branch.The JavaScript execution model has not changed. TypeScript adds a check before the emitted program runs.
Once exact matching, fall-through, and case-local scope feel predictable, the same control-flow rules become easier to recognize inside larger functions. JavaScript Fundamentals collects the surrounding lessons on expressions, conditions, loops, functions, and the language rules those structures share.