Many conditions are unnecessary or could be rewritten in a more readable way.
For example, consider the following code that creates two boolean variables:
Both value !== '' and products.length > 0 already return boolean values, so we can avoid using the ternary operator:
Even when the initial value isn’t a boolean:
We can still avoid the condition by explicitly converting the value to a boolean:
In all cases, the code without ternaries is both shorter and easier to read.
Let’s have a look at another example of an unnecessary condition:
First, the Array.isArray() method returns false for any falsy value, so there’s no need to check this separately. Second, in most cases, we can use the optional chaining operator instead of an explicit array check.
Info A falsy value is a value that is considered false during type conversion to a boolean, and includes false, null, undefined, 0, '', and a few others.
Info The optional chaining operator (?.) was introduced in ECMAScript 2020 and allows us to access methods or properties of an object only when they exist, so we don’t need to wrap the code in an if condition.
The only case when this code might break is if products is a string, as strings also have the length property.
Tip I consider a variable that can be either undefined (or null) or an array an antipattern in most cases. I would track the source of this value, make sure that it’s always an array, and use an empty array instead of undefined. This way we can skip a lot of conditions and simplify types: we can just use products.length > 0, and not worry that products may not have the length property.
Here’s a more complex but great (and real!) example of unnecessary conditions:
This code checks whether the user has a particular browser and operating system by testing the user agent string. We can replace the nested condition with a single expression that returns a boolean value:
By eliminating two levels of nesting and reducing boilerplate code, we made the actual condition clearer.
I often see two conditions for a single boolean variable:
In the code above, we compare type three times and hasUserSelectableRefundOptions twice, which is unnecessary and makes the code confusing:
We had to split the component into two to use early return, but the logic is now clearer.
We often add conditions when some data might be missing. For example, an optional callback function:
In the code above, the onError parameter is optional, and we check if it exists before calling it. The problem here is that we need to remember to wrap each call to an optional callback with a condition. It increases complexity and cognitive load and makes the code harder to read.
One way to simplify the code here is by using the optional chaining operator:
It looks neater; however, it has the same issues as the if statement.
I usually try to avoid these kinds of conditions and make sure all optional parameters are available, even if empty, so I can access them without checking if they are available first.
My favorite way to do it is by lifting the condition to the function head using optional function parameters:
Now, it’s safe to call the onError() function whenever we need it. It won’t do anything if we don’t pass it to the function, but we don’t need to worry about this while we’re coding the function itself.
It’s common to check an array’s length before running a loop over its elements:
All loops and array functions, like map(), or filter(), work fine with empty arrays, so we can safely remove the check:
Sometimes, we have to use an existing API that returns an array only in some cases, so checking the length directly would fail, and we need to check the type first:
We can’t avoid the condition in this case, but we can lift it to the function head and avoid having a separate branch that handles the absence of an array. There are several ways to do this, depending on the possible data types.
If our data can be an array or undefined, we can use a default value for the function parameter:
Or we can use a default value for the destructured property of an object:
It’s trickier if our data can be an array or null because default values are only used when the value is strictly undefined, not just falsy. In this case, we can use the nullish coalescing operator:
We still have a condition, but the overall code structure is simpler.
Info The nullish coalescing operator (??) was introduced in ECMAScript 2020 and gives us a better alternative to the logical or operator (||) because it only checks for nullish values (undefined or null), not for falsy values (which would also include, often undesirable, false, '', and 0).
In all these examples, we’re removing a separate branch that deals with the absence of data by normalizing the input — converting it to an array — as early as possible and then running a generic algorithm on the normalized data.
Arrays are convenient because we don’t have to worry about how many elements they contain: the same code will work with a hundred elements, one element, or zero elements.
A similar technique works when the input is a single value or an array:
In the code above, we wrap a single element with an array so we can use the same code to work with single values and arrays.
Examples in the previous section introduce an important technique: algorithm deduplication. Instead of branching the main logic based on the input type, we code the main logic only once, but we normalize the input before running the algorithm. This technique can be used in many other places.
Imagine an article with a “Like” button and a counter, where every time we press the button, the counter number increases. The object that stores counters for all articles could look like this:
A naïve way to implement the upvote() method might be:
The problem here is that the main function’s logic, incrementing the count, is implemented twice: for the case when we have already voted for that URL and when we’re voting for the first time. So, every time we need to update this logic, we have to make changes in two places. We need to write two sets of very similar tests to make sure both branches work as expected, otherwise, they’ll eventually diverge, and we’ll have hard-to-debug issues.
Let’s make the main logic unconditional, and prepare the state if necessary before running this logic:
Now, we don’t have any logic duplication. Instead, we normalize the data structure so the generic algorithm can work with it.
I often see a similar issue when someone calls a function with different parameters:
Let’s move the condition inside the function call:
We’ve removed all code duplication, and the code is shorter and easier to read. It’s also easier to see exactly which values depend on the condition.
A series of nested conditions is an unfortunate but popular way of handling errors:
The main code of this function is on the fourth level of nesting. We need to scroll all the way to the end of the function to see the else parts of each condition, which makes it easy to edit the wrong block because the conditions and their else blocks are so far apart. The else blocks are also in reversed order, which makes the code even more confusing.
Info Deeply nested conditions are also known as the arrow antipattern, or dangerously deep nesting, or if/else hell.
Early returns, or guard clauses, are a great way to avoid nested conditions and make the code easier to understand:
Now, the whole validation is grouped at the beginning of the function using guard clauses, and it’s clear which error message is shown for each validation. We have at most one level of nesting instead of four, and the main code of the function isn’t nested at all.
Here’s a real-life example:
This code is building an array with information about orders in an online shop. There are 120 lines between the first condition and its else block, and the main return value is somewhere inside the three levels of conditions.
Let’s untangle this spaghetti monster:
This function is still long, but it’s much easier to follow because its structure is more straightforward.
Now, we have at most one level of nesting inside the function, and the main return value is at the very end without nesting. We’ve added two guard clauses to exit the function early when there’s no data to process.
Info One of the Zen of Python’s principles is flat is better than nested, which is exactly what we did with this refactoring. I also call it code flattening.
I’m not so sure what the code inside the second condition does, but it looks like it’s wrapping a single value in an array, as we did earlier in this chapter.
And no, I have no idea what tmpBottle means or why it was needed.
The next step here could be improving the getOrderIds() function’s API. It can return three different things: undefined, a single value, or an array. We have to deal with each separately, so we have two conditions at the beginning of the function, and we’re reassigning the idsArrayObj variable.
By making the getOrderIds() function always return an array and making sure that the code inside the // Skipped 70 lines of code building the array… works with an empty array, we could remove both conditions:
Now, that’s a big improvement over the initial version. I’ve also renamed the variables because “array object” doesn’t make any sense to me and the “array” suffix is unnecessary.
The next step would be out of the scope of this chapter: the code inside the // Skipped 70 lines of code building the array… mutates the fullRecords. I usually try to avoid mutation, especially for variables with such a long lifespan.
I have trouble reading nested ternaries in general and prefer not to nest them. This is an extreme example of nesting: the good path code, rendering the Component, is quite hidden. This is a perfect use case for guard clauses.
Let’s refactor it:
In the code above, the default, happy path isn’t intertwined with the exceptional cases. The default case is at the very bottom of the component, and all exceptions are in front, as guard clauses.
Tip We discuss a better way of managing loading and error states in the Make impossible states impossible section.
One of my favorite techniques for improving (read: avoiding) conditions is replacing them with tables or maps. In JavaScript, we can create a table or a map using a plain object.
This example may seem extreme, but I actually wrote this code in my early twenties:
Let’s replace these conditions with a table:
There’s almost no boilerplate code around the data; it’s more readable and looks like a table. Notice also that there are no braces in the original code: in most modern style guides, braces around condition bodies are required, and the body should be on its own line, so this code would be three times longer and even less readable.
Another issue with the initial code is that the month variable’s initial type is a string, but then it becomes a number. This is confusing, and if we were using a typed language (like TypeScript), we would have to check the type every time we wanted to access this variable.
Here’s a bit more realistic and common example:
In the code above, we have a switch statement that returns one of the three button labels.
First, let’s replace the switch with a table:
The object syntax is a bit more lightweight and readable than the switch statement.
We can simplify the code even more by inlining the getButtonLabel() function:
One thing I like to do on TypeScript projects is to combine tables with enums:
In the code above, we define an enum for decisions, and we use it to ensure consistency in the button label map and decision button component props:
The decision button component accepts only known decisions.
The button label map can have only known decisions and must have them all. This is especially useful: every time we update the decision enum, TypeScript makes sure the map is still in sync with it.
Also, enums make the code cleaner than SCREAMING_SNAKE_CASE constants.
This changes the way we use the DecisionButton component:
We can achieve the same safety even without enums, and I usually prefer this way for React components because it simplifies the markup. We can use plain strings instead of an enum:
This again changes the way we use the DecisionButton component:
Now, the markup is simpler and more idiomatic. We don’t need to import an enum every time we use the component, and we get a nice autocomplete for the decision prop value.
Another realistic and common example is form validation:
This function is very long, with lots and lots of repetitive boilerplate code. It’s really hard to read and maintain. Sometimes, validations for the same field aren’t together, which makes it even harder to understand all the requirements for a particular field.
However, if we look closely, there are only three unique validations:
required field (in some cases leading and trailing whitespace is ignored, in others not — hard to tell whether it’s intentional or not);
maximum length (always 80 characters);
spaces are not allowed.
First, let’s extract all validations into their own functions so we can reuse them later:
I assumed that different whitespace handling was a bug. I’ve also inverted all the conditions to validate the correct value, instead of an incorrect one, to make the code more readable.
Note that hasLengthLessThanOrEqual() and hasNoSpaces() functions only check the condition if the value is present, which would allow us to make optional fields. Also, note that the hasLengthLessThanOrEqual() function is customizable: we need to pass the maximum length: hasLengthLessThanOrEqual(80).
Now, we can define our validation table. There are two ways of doing this:
using an object where keys represent form fields;
using an array.
We’ll use an array because we want to have several validations with different error messages for some fields. For example, a field can be required and have a maximum length:
Next, we need to iterate over this array and run validations for all the fields:
Once again, we’ve separated the “what” from the “how”: we have a readable and maintainable list of validations (“what”), a collection of reusable validation functions, and a generic validate() function to validate form values (“how”) that we can reuse to validate other forms.
Info We talk about the separation of “what” and “how” in the Separate “what” and “how” section of the Divide and conquer, or merge and relax chapter.
Tip Using a third-party library, like Zod, Yup, or Joi will make code even shorter and save us from needing to write validation functions ourselves.
You may feel that I have too many similar examples in this book, and you’re right. However, I think such code is so common, and the readability and maintainability benefits of replacing conditions with tables are so huge that it’s worth repeating.
So here is another example (the last one, I promise!):
It’s only 15 lines of code, but I find this code difficult to read. I think that the switch statement is unnecessary, and the datePart and monthPart variables clutter the code so much that it’s almost impossible to read.
Let’s try to replace the switch statement with a map, and inline datePart and monthPart variables:
The improved version is shorter, and, more importantly, now it’s easy to see all date formats: now the difference is much easier to spot.
Info There’s a proposal to add pattern matching to JavaScript, which may give us another option: more flexible than tables but still readable.
Negative conditions are generally harder to read than positive ones:
Decoding “if not enabled” takes a bit more cognitive effort than “if enabled”:
One notable exception is early returns, which we discussed earlier in this chapter. While negative conditions are harder to read, the overall benefit of structuring functions with early returns outweighs this drawback.
We often need to compare a variable to multiple values. A naïve way to do this is by comparing the variable to each value in a separate clause:
In the code above, we have three clauses that compare the size variable to three different values, making the values we compare it to far apart. Instead, we can group them into an array and use the includes() array method:
Now, all the values are grouped together, making the code more readable and maintainable. It’s also easier to add and remove items.
Repeated conditions can make code barely readable. Consider this function that returns special offers for products in a pet shop. The shop has two brands: Horns & Hooves and Paws & Tails, each with unique special offers. Historically, they are stored in the cache differently:
The isHornsAndHooves condition is repeated three times. Twice to create the same session key. It’s hard to see what this function is doing: business logic is intertwined with low-level session management code.
Let’s try to simplify it a bit:
Now, the code is already more readable, and we can stop here. However, if I had some time, I’d go further and extract cache management. Not because this function is too long or potentially reusable, but because cache management distracts from the main purpose of the function and is too low-level.
It may not look much better, but I think it’s a bit easier to understand what’s happening in the main function. What’s annoying here is isHornsAndHooves. I’d rather pass a brand and keep all brand-specific information in tables:
Now, all brand-specific code is grouped together and clear, making the algorithm generic.
Ideally, we should check whether we can implement caching the same way for all brands: this would simplify the code further.
It may seem like I prefer small or even very small functions, but that’s not the case. The main reason for extracting code into separate functions here is that it violates the single responsibility principle. The original function had too many responsibilities: getting special offers, generating cache keys, reading data from the cache, and storing data in the cache, each with two branches for our two brands.
Info The single responsibility principle states that any module, class, or method should have only one reason to change, or, in other words, we should keep the code that changes for the same reason together. Info: Info: Imagine a pizzeria where a pizzaiolo is responsible only for cooking pizzas, and a cashier is responsible only for charging customers. In other words, we don’t murder people, and they don’t plaster the walls. Info: Info: We talk more about this topic in the Divide and conquer, or merge and relax chapter.
Let’s have a look at one more example:
This function calculates the maximum discount between a user’s personal discount and a site-wide promotion, returning a default value of 0 if neither is present.
My brain is refusing to even try to understand what’s going on here. There’s so much nesting and repetition that it’s hard to see whether this code is doing anything at all.
Let’s try to simplify it a bit:
In the code above, we create an array with all possible discounts, then we use Lodash’s maxBy() method to find the maximum discount value, and finally, we use the nullish coalescing operator to either return the maximum or 0.
Now, it’s clear that we want to find the maximum of two types of discounts, otherwise return 0.
Similar to tables, a single expression, or a formula can often replace a whole bunch of conditions. Consider this example:
The problem with this code isn’t that it’s especially hard to understand, but that it has a very large surface for bugs: every number and every condition could be wrong, and there are lots of them here. This code also needs many test cases to make sure it’s correct.
Let’s try to replace conditions with a formula:
It’s harder to understand than the initial implementation, but it requires significantly fewer test cases, and we’ve separated the design and the code. The icons will likely change, but the algorithm probably won’t.
A ternary operator, or just a ternary, is a short, one-line conditional operator. It’s useful when we want to assign one of two values to a variable. Let’s take this if statement as an example:
Now, compare it to a ternary:
However, nested ternaries are different beasts: they make code harder to read because it’s difficult to see which branch belongs to which condition. There’s almost always a better alternative.
This is a rare case where Prettier makes the code completely unreadable:
But maybe it’s intentional and gives us a clear sign that we should rewrite this code.
Info We talk about code formatting and Prettier in the Autoformat your code chapter.
In this example, we render one of four UI states:
a spinner (loading);
an error message (failure);
a list of products (success);
a “no products” message (also success).
Let’s rewrite this code using the already familiar early return pattern:
I think it’s much easier to follow now: all special cases are at the top of the function, and the happy path is at the end.
Info We’ll come back to this example later in the Make impossible states impossible section of the Other techniques chapter.
Sometimes, we can’t reduce the number of conditions, and the only way to improve the code is to make it easier to understand what a certain complex condition does.
This code checks multiple conditions, such as the user’s browser or the state of the widget. However, all these checks are crammed into a single expression, making it hard to understand. It’s often a good idea to extract complex calculations and conditions from an already long expression into separate variables:
Now, the condition is shorter and more readable because names help us to understand what the condition does in the context of the function where it’s used.
Here’s another example:
I wrote this code myself, but now it takes me a long time to understand what’s going on. We get a list of tips, and we keep only those that are suitable for the current recipe: it has the ingredient matching any of the ingredients or it has tags matching all the tags.
Let’s try to make it clearer:
The code is noticeably longer, but it’s less dense and doesn’t try to do everything at once. We start by saving ingredient names to make it easier to compare later. Then, inside the filter() callback function, we check whether the tip’s ingredient matches any of the recipe’s ingredients (but only if the tip specifies the ingredient), and finally we check whether all tip’s tags are present in the recipe’s tags.
Info The Naming is hard chapter has a few more examples of extracting complex conditions.
Conditions allow us to write generic code that supports many use cases. However, when the code has too many conditions, it becomes hard to read and test. We should be vigilant and avoid unnecessary conditions, or replace some conditions with more maintainable and testable alternatives.
Start thinking about:
Removing unnecessary conditions, such as explicitly comparing a boolean value to true or false.
Normalizing the input data by converting the absence of data to an array early on to avoid branching and dealing with no data separately.
Normalizing the state to avoid algorithm duplication.
Replacing complex condition with a single expression (formula) or a map.
Replacing nested ternaries or if operators with early returns.