Why You Don't Need the Switch/Case Operator

Table of Contents
A small disclaimer. I am not urging you to completely abandon this instruction, nor do I insist on absolute truth and correctness. I only want to provide reasoned arguments; the conclusion is up to you.
I am sure that many, when starting to program, come to the thought that it is simple. Far fewer developers step over this idea and realize that it is very difficult. It is difficult to write not just working programs, but maintainable, thoughtful ones, those that can be read like a well-written book. I will probably not open America by saying that a programmer spends ~90% of their time reading code, and only the remaining 10% writing it. This is quite logical, thus summarizing the 1st axiom: good code is code that is easy to read, code written for people, not for machines. This is a fairly subtle understanding, and one needs to come to it.
Of course, some methodologies will tell you that good code is primarily working code. In fact, these are very close concepts. You cannot guarantee that poorly written code works. I am sure many will object at this moment, “Well, what difference does it make how it is written? We have tests, unit, e2e, and a whole staff of testers who click through the system all day long!”.
It would seem that this makes sense, but let’s think about who writes the tests. If the code is written poorly, what separates us from poor-quality tests? Tests that test not what is required, or test incorrectly.
So, returning to the main topic, what is wrong with switch case? (of course, in my subjective opinion).
What is the problem with switch/case, and does it exist at all? #
- This operator creates an additional level of nesting (of course, not in all languages). The more nesting we have, the harder it is to read, and most importantly, understand the code. Let’s consider a small example:
switch (vehicle) {
case vehicle.type === "car":
if (vehicle.hasBenzin && vehicle.isWork) {
vehicle.ride();
} else {
if (vehicle.hasInsuarence) {
vehicle.support.call();
}
}
case vehicle.type === "bicycle":
if (vehicle.isWork) {
vehicle.ride();
} else {
if (vehicle.couldRecover) {
vehicle.recover();
} else {
vehicle.writeOff();
}
}
}
In this example, the problem is not only in switch case, however, I very frequently encounter similar code (with much greater branching). In the next article on conditional operators, I will try to talk about the downsides of else.
This approach potentially violates the Single Responsibility Principle (SRP, see example above). Of course, we can call functions in each of the cases; we can solve this problem. So what is the problem? The problem is that this code will be a potential place where there is an opportunity to write code (this can be done due to lack of qualification, or when deadlines are pressing), making the code more cumbersome and less readable. Most often we work in a team, in a team where each member has their own understanding, their own level, and their own development experience. Yes, of course, this will be visible in code review, but why this extra link? If it can simply be avoided.
This point flows from the previous one - testing. It is much easier to test each individual case as a function than to write 10 tests for the entire switch/case, potentially skipping a couple.
break? Needed? Not needed? I blindly believe that comments in code (not to be confused with public SDK methods and formula descriptions) are evil; you can read more about this in “Clean Code” by Robert Martin. In short, comments become outdated.., code changes, but they often do not. The problem with the break operator is that it is unclear: did the developer simply forget to write it? Or is this intended behavior. Yes, a comment solves this problem, but comments cannot be trusted.
A small example of how to kill an abstract little human :(
class Pedestrian {
public crossTheRoad(): void {
/* impl */
}
public wait(): void {
/* impl */
}
public beCareful(): void {
/* impl */
}
public lookAtLights(): void {
/* impl */
}
public tryToRun(): void {
console.log("accident");
}
}
const p = new Pedestrian();
enum Color {
RED = "red",
YELLOW = "yellow",
GREEN = "green",
}
let a: any = Color.RED;
switch (a) {
case Color.YELLOW:
p.beCareful();
break;
case Color.GREEN:
p.crossTheRoad();
case Color.RED:
p.wait();
// Did the developer intentionally not put a break? Or was it done consciously?
default:
p.tryToRun();
}
In this case, everything is quite obvious, but this is just an example. In real life, cases are significantly more complex, and even if they are simple, in the future, they can become overgrown with logic and potential places for errors. And who then will be guilty of the death of our abstract little human?
- This problem is closely related to the previous point. If we are talking about the break instruction, we must understand how the language is structured (in some languages, this operator is called by default). And, in principle, this isn’t a big problem, however, considering the different levels of developers, this is 1 more potential place where one can catch unforeseen behavior. Code should be as dumb as possible so that anyone can read it without thinking too much. In the case of break, we will be forced to think about it.
How to make code more concise #
Okay, I seem to have described the problem, let’s move on to how it can be solved. Let’s look at a series of examples that show solutions for different situations.
Some of the examples are trivial, and I am sure most developers would never create such an implementation. However, I have encountered such code in actually working products, and I have encountered it much more often than it might seem at first glance.
Let’s start with the simple one, investigating the previous example where we inadvertently killed the little human.
class Pedestrian {
public crossTheRoad(): void {
/* impl */
}
public wait(): void {
/* impl */
}
public beCareful(): void {
/* impl */
}
public lookAtLights(): void {
/* impl */
}
public tryToRun(): void {
console.log("accident");
}
}
const p = new Pedestrian();
enum Color {
RED = "red",
YELLOW = "yellow",
GREEN = "green",
}
let a: any = Color.RED;
const actions: Record<Color, (p: Pedestrian) => any> = {
[Color.RED]: (p) => p.wait(),
[Color.GREEN]: (p) => p.crossTheRoad(),
[Color.YELLOW]: (p) => p.beCareful(),
};
actions[a] ? actions[a](p) : p.wait();
As seen from the example, the code has become significantly smaller in volume, and we have also moved it into separate functions (anonymous functions are used in the example, but we can also use regular ones, which will allow testing each of them, as well as viewing the function context without paying attention to neighboring blocks). Furthermore, we don’t have implicit behavior as in the case with break. And what is more important, this code is easier to read.
Okay, everything seems more or less clear with the simple example, let’s consider a more complex example with multiple conditions.
An implementation with switch/case would look something like this:
const v = Math.round(Math.random() * 10);
switch (true) {
case v % 2 === 0 && v < 6:
console.log("V is even and less then 6");
break;
case v === 10:
console.log("its 10");
break;
case v % 2 === 1 && v > 5:
console.log("V is odd and greater than 5");
default:
console.log("something else");
}
Let’s try to make this code more linear. The first thing that comes to mind is replacing it with a conditional operator:
function getMessageFromN(n: number): void {
if (v % 2 === 0 && v < 6) {
console.log("V is even and less then 6");
return;
}
if (v === 10) {
console.log("its 10");
return;
}
if (v % 2 === 1 && v > 5) {
console.log("V is odd and greater than 5");
return;
}
console.log("something else");
}
From the example, it is clear that we got rid of 1 more level of nesting, however, the other problems remained. We have a potential place for code expansion inside the conditional operators. Let’s try to do better:
function isTen(n: number): boolean {
return n === 10;
}
function isOddAndLessThen6(n: number): boolean {
return v % 2 === 0 && v < 6;
}
function isEvenAndGreaterThen5(n: number): boolean {
return v % 2 === 1 && v > 5;
}
const compares = [
{ method: isTen, action: () => console.log("Its ten") },
{
method: isOddAndLessThen6,
action: () => console.log("V is even and less then 6"),
},
{
method: isEvenAndGreaterThen5,
action: () => console.log("V is odd and greater than 5"),
},
// default
{ method: () => true, action: () => console.log("something else") },
];
function getMessageFromN(n: number): void {
compares.every((c) => {
if (c.method(n)) {
c.action();
return false;
}
return true;
});
}
The code turned out to be larger than with switch/case, so where is the benefit here? Firstly, we moved each of the conditional checks into a separate function, which will allow them to be tested separately. The second important point is the absence of branching in the program; the decomposed code has become easier to parse. In this example, I used an object, but we can also use the Strategy pattern, isolating each condition into a separate object. The benefit of using this approach can be felt with a large number of conditional operators; in such a program, I would suggest using if with the extraction of checks into methods.
P.S. I would also like to note 1 nuance: until version 3.10, the switch/case operator was absent in the Python language, and this was not done by accident. Nevertheless, developers successfully wrote code without it. Python 3.10 introduced match/case, which is a powerful structural pattern matching tool, not just a switch analog.