Skip to content

Washing your code: avoid conditions

You’re reading an excerpt of my upcoming book on clean code for frontend developers, “Washing your code.”

Get the book now!

Washing your code book cover

Conditions are essential for writing code that supports multiple use cases. JavaScript offers multiple ways to write conditional code:

// `if` operator
if (condition) {
  // The condition is true
} else {
  // The condition is false
}
 
// `switch` operator
switch (value) {
  case 'value1': {
    // Code for value1
    break;
  }
  case 'value2': {
    // Code for value2
    break;
  }
  default: {
    // Code if no cases match
    break;
  }
}
 
// Ternary operator
const value1 = condition ? 'true value' : 'false value';
 
// Optional chaining operator
const value2 = object.getValue?.();
 
// Nullish coalescing operator
const value3 = value ?? 'default value';

We’ll talk about each type in more detail in this chapter.

However, conditions can make code harder to read and test because:

  • conditions add nesting and increase code complexity;
  • multipart conditions are even harder to understand, especially those that mix positive and negative clauses;
  • each condition increases the minimum number of test cases we need to write for a certain module or function.

Thus, reducing the number of conditions in our code makes sense.

#Unnecessary conditions

Many conditions are unnecessary or could be rewritten in a more readable way.

For example, consider the following code that creates two boolean variables:

const value = '';
const hasValue = value !== '' ? true : false;
// → false
 
const products = ['taco'];
const hasProducts = products.length > 0 ? true : false;
// → true

Both value !== '' and products.length > 0 already return boolean values, so we can avoid using the ternary operator:

const value = '';
const hasValue = value !== '';
// → false
 
const products = ['taco'];
const hasProducts = products.length > 0;
// → true

Even when the initial value isn’t a boolean:

const value = '';
const hasValue = value ? true : false;
// → false

We can still avoid the condition by explicitly converting the value to a boolean:

const value = '';
const hasValue = Boolean(value);
// → false

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:

const products = ['taco'];
const hasProducts =
  products && Array.isArray(products) && products.length > 0;
// → true

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.

Infofalsy value is a value that is considered false during type conversion to a boolean, and includes false, null, undefined, 0, '', and a few others.

const products = ['taco'];
const hasProducts = products?.length > 0;
// → true

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:

function isNetscapeOnSolaris() {
  var agent = window.navigator.userAgent;
  if (
    agent.indexOf('Mozilla') != -1 &&
    agent.indexOf('compatible') == -1
  ) {
    if (agent.indexOf('SunOS') != -1) return true;
    else return false;
  } else {
    return false;
  }
}

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:

function isNetscapeOnSolaris() {
  const { userAgent } = window.navigator;
  return (
    userAgent.includes('Mozilla') &&
    userAgent.includes('SunOS') &&
    userAgent.includes('compatible') === false
  );
}

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:

const RefundLabel = ({
  htmlFor,
  type,
  typeLabels,
  hasUserSelectableRefundOptions
}) => (
  <label htmlFor={htmlFor}>
    {type && typeLabels[type]}
    {!type && hasUserSelectableRefundOptions && 'Estimated:'}
    {!type && !hasUserSelectableRefundOptions && 'Total:'}
  </label>
);

In the code above, we compare type three times and hasUserSelectableRefundOptions twice, which is unnecessary and makes the code confusing:

const RefundLabelMessage = ({
  type,
  typeLabels,
  hasUserSelectableRefundOptions
}) => {
  if (type) {
    return typeLabels[type];
  }
 
  return hasUserSelectableRefundOptions ? 'Estimated:' : 'Total:';
};
 
const RefundLabel = ({ htmlFor, ...props }) => (
  <label htmlFor={htmlFor}>
    <RefundLabelMessage {...props} />
  </label>
);

We had to split the component into two to use early return, but the logic is now clearer.

#Optional function parameters

We often add conditions when some data might be missing. For example, an optional callback function:

function getRandomJoke(onDone, onError) {
  fetch('https://api.chucknorris.io/jokes/random')
    .then(result => result.json())
    .then(data => {
      onDone(data);
    })
    .catch(error => {
      if (onError) {
        onError(error.message);
      }
    });
}

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.

Info The cognitive load is the mental effort required to understand the code. Artem Zakirullin wrote a great article on cognitive load in programming.

One way to simplify the code here is by using the optional chaining operator:

function getRandomJoke(onDone, onError) {
  fetch('https://api.chucknorris.io/jokes/random')
    .then(result => result.json())
    .then(data => {
      onDone(data);
    })
    .catch(error => {
      onError?.(error.message);
    });
}

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:

function getRandomJoke(onDone, onError = () => {}) {
  fetch('https://api.chucknorris.io/jokes/random')
    .then(result => result.json())
    .then(data => {
      onDone(data);
    })
    .catch(error => {
      onError(error.message);
    });
}

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.

#Processing arrays

It’s common to check an array’s length before running a loop over its elements:

function getProductsDropdownItems(response) {
  const products = response.products;
  if (products.length > 0) {
    return products.map(product => ({
      label: product.name,
      value: product.id
    }));
  }
  return [];
}

All loops and array functions, like map(), or filter(), work fine with empty arrays, so we can safely remove the check:

function getProductsDropdownItems({ products }) {
  return products.map(product => ({
    label: product.name,
    value: product.id
  }));
}

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:

function getProductsDropdownItems({ products }) {
  if (Array.isArray(products) && products.length > 0) {
    return products.map(product => ({
      label: product.name,
      value: product.id
    }));
  }
  return [];
}

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:

function getProductsDropdownItems(products = []) {
  return products.map(product => ({
    label: product.name,
    value: product.id
  }));
}

Or we can use a default value for the destructured property of an object:

function getProductsDropdownItems({ products = [] }) {
  return products.map(product => ({
    label: product.name,
    value: product.id
  }));
}

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:

function getProductsDropdownItems(productsMaybe) {
  const products = productsMaybe ?? [];
  return products.map(product => ({
    label: product.name,
    value: product.id
  }));
}

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:

function getProductsDropdownItems({ products: productOrProducts }) {
  const products = Array.isArray(productOrProducts)
    ? productOrProducts
    : [productOrProducts];
  return products.map(product => ({
    label: product.name,
    value: product.id
  }));
}

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.

#Deduplicating algorithms

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:

const articles = counter();
articles.upvote('/cats-better-than-dogs');
articles.upvote('/dogs-better-than-cats', 5);
articles.downvote('/dogs-better-than-cats');
articles.get('/cats-better-than-dogs');
// → 1
articles.get('/dogs-better-than-cats');
// → 4

A naïve way to implement the upvote() method might be:

function counter() {
  const counts = {};
  return {
    get(url) {
      return counts[url];
    },
    upvote(url, votes = 1) {
      if (url in counts) {
        counts[url] += votes;
      } else {
        counts[url] = votes;
      }
    }
  };
}

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:

function counter() {
  const counts = {};
  return {
    get(url) {
      return counts[url];
    },
    upvote(url, votes = 1) {
      if (counts[url] === undefined) {
        counts[url] = 0;
      }
 
      counts[url] += votes;
    }
  };
}

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:

if (errorMessage) {
  log(LOG_LEVEL.ERROR, errorMessage);
} else {
  log(LOG_LEVEL.ERROR, DEFAULT_ERROR_MESSAGE);
}

Let’s move the condition inside the function call:

log(LOG_LEVEL.ERROR, errorMessage || DEFAULT_ERROR_MESSAGE);

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.

#Early returns

A series of nested conditions is an unfortunate but popular way of handling errors:

function addUser(user) {
  if (isUsernameValid(user.username)) {
    if (isEmailValid(user.email)) {
      if (isAddressValid(user.address)) {
        createUserRecord(user);
        showNotification('Welcome to The Hell!');
      } else {
        throw new Error('You must enter a valid address');
      }
    } else {
      throw new Error('You must enter a valid email');
    }
  } else {
    throw new Error('You must enter a valid username');
  }
}

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:

function addUser(user) {
  if (isUsernameValid(user.username) === false) {
    throw new Error('You must enter a valid address');
  }
  if (isEmailValid(user.email) === false) {
    throw new Error('You must enter a valid email');
  }
  if (isAddressValid(user.address) === false) {
    throw new Error('You must enter a valid username');
  }
 
  createUserRecord(user);
  showNotification('Welcome to The Hell!');
}

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:

function postOrderStatus() {
  var idsArrayObj = getOrderIds();
 
  if (idsArrayObj != undefined) {
    if (idsArrayObj.length == undefined) {
      var tmpBottle = idsArrayObj;
      idsArrayObj = new Array(tmpBottle);
    }
 
    var fullRecordsArray = new Array();
 
    // Skipped 70 lines of code building the array…
 
    if (fullRecordsArray.length != 0) {
      // Skipped some 40 lines of code…
      return sendOrderStatus(fullRecordsArray);
    } else {
      return false;
    }
  } else {
    return false;
  }
}

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:

function postOrderStatus() {
  let idsArrayObject = getOrderIds();
  if (idsArrayObject === undefined) {
    return false;
  }
 
  if (Array.isArray(idsArrayObject) === false) {
    idsArrayObject = [idsArrayObject];
  }
 
  const fullRecordsArray = [];
 
  // Skipped 70 lines of code building the array…
 
  if (fullRecordsArray.length === 0) {
    return false;
  }
 
  // Skipped some 40 lines of code…
  return sendOrderStatus(fullRecordsArray);
}

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.

Info We talk about reassignments in the next chapter, Avoid reassigning variables.

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:

function postOrderStatus() {
  const orderIds = getOrderIds(); // Always an array
 
  const fullRecords = [];
 
  // Skipped 70 lines of code building the array…
 
  if (fullRecords.length === 0) {
    return false;
  }
 
  // Skipped some 40 lines of code…
  return sendOrderStatus(fullRecords);
}

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.

Info We talk about naming in the Naming is hard chapter, and about mutation in the Avoid mutation chapter.

Consider another example:

function Container({
  component: Component,
  isError,
  isLoading,
  data
}) {
  return isError ? (
    <ErrorMessage />
  ) : isLoading ? (
    <LoadingSpinner />
  ) : data.length > 0 ? (
    <Component data={data} />
  ) : (
    <EmptyMessage />
  );
}

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:

function Container({
  component: Component,
  isError,
  isLoading,
  data
}) {
  if (isError) {
    return <ErrorMessage />;
  }
 
  if (isLoading) {
    return <LoadingSpinner />;
  }
 
  if (data.length === 0) {
    return <EmptyMessage />;
  }
 
  return <Component data={data} />;
}

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.

#Tables and maps

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:

function getMonthNumberByName(month) {
  if (month == 'jan') month = 1;
  if (month == 'feb') month = 2;
  if (month == 'mar') month = 3;
  if (month == 'apr') month = 4;
  if (month == 'may') month = 5;
  if (month == 'jun') month = 6;
  if (month == 'jul') month = 7;
  if (month == 'aug') month = 8;
  if (month == 'sep') month = 9;
  if (month == 'oct') month = 10;
  if (month == 'nov') month = 11;
  if (month == 'dec') month = 12;
  return month;
}

Let’s replace these conditions with a table:

const MONTH_NAME_TO_NUMBER = {
  jan: 1,
  feb: 2,
  mar: 3,
  apr: 4,
  may: 5,
  jun: 6,
  jul: 7,
  aug: 8,
  sep: 9,
  oct: 10,
  nov: 11,
  dec: 12
};
 
function getMonthNumberByName(monthName) {
  return MONTH_NAME_TO_NUMBER[monthName];
}

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:

const DECISION_YES = 0;
const DECISION_NO = 1;
const DECISION_MAYBE = 2;
 
const getButtonLabel = decisionButton => {
  switch (decisionButton) {
    case DECISION_YES: {
      return 'Yes';
    }
    case DECISION_NO: {
      return 'No';
    }
    case DECISION_MAYBE: {
      return 'Maybe';
    }
  }
};
 
function DecisionButton({ decision }) {
  return <button>{getButtonLabel(decision)}</button>;
}

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:

const DECISION_YES = 0;
const DECISION_NO = 1;
const DECISION_MAYBE = 2;
 
const BUTTON_LABELS = {
  [DECISION_YES]: 'Yes',
  [DECISION_NO]: 'No',
  [DECISION_MAYBE]: 'Maybe'
};
 
const getButtonLabel = decisionButton =>
  BUTTON_LABELS[decisionButton];
 
function DecisionButton({ decision }) {
  return <button>{getButtonLabel(decision)}</button>;
}

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:

const DECISION_YES = 0;
const DECISION_NO = 1;
const DECISION_MAYBE = 2;
 
const BUTTON_LABELS = {
  [DECISION_YES]: 'Yes',
  [DECISION_NO]: 'No',
  [DECISION_MAYBE]: 'Maybe'
};
 
function DecisionButton({ decision }) {
  return <button>{BUTTON_LABELS[decision]}</button>;
}

One thing I like to do on TypeScript projects is to combine tables with enums:

enum Decision {
  Yes = 0,
  No = 1,
  Maybe = 2
}
 
const BUTTON_LABELS: Record<Decision, string> = {
  [Decision.Yes]: 'Yes',
  [Decision.No]: 'No',
  [Decision.Maybe]: 'Maybe'
};
 
function DecisionButton({ decision }: { decision: Decision }) {
  return <button>{BUTTON_LABELS[decision]}</button>;
}

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:

- <DecisionButton decision={DECISION_MAYBE} />
+ <DecisionButton decision={Decision.Maybe} />

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:

type Decision = 'yes' | 'no' | 'maybe';
 
const BUTTON_LABELS: Record<Decision, string> = {
  yes: 'Yes',
  no: 'No',
  maybe: 'Maybe'
};
 
function DecisionButton({ decision }: { decision: Decision }) {
  return <button>{BUTTON_LABELS[decision]}</button>;
}

This again changes the way we use the DecisionButton component:

- <DecisionButton decision={Decision.Maybe} />
+ <DecisionButton decision="maybe" />

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:

function validate(values) {
  const errors = {};
 
  if (!values.name || (values.name && values.name.trim() === '')) {
    errors.name = 'Name is required';
  }
 
  if (values.name && values.name.length > 80) {
    errors.name = 'Maximum 80 characters allowed';
  }
 
  if (!values.address1) {
    errors.address1 = 'Address is required';
  }
 
  if (!values.email) {
    errors.email = 'Email is required';
  }
 
  if (!values.login || (values.login && values.login.trim() === '')) {
    errors.login = 'Login is required';
  }
 
  if (values.login && values.login.indexOf(' ') > 0) {
    errors.login = 'No spaces are allowed in login';
  }
 
  if (values.address1 && values.address1.length > 80) {
    errors.address1 = 'Maximum 80 characters allowed';
  }
 
  // Skipped some 100 lines of other validations…
 
  return errors;
}

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:

/**
 * Validates whether a string is not empty,
 * ignores leading and trailing whitespace
 */
const hasStringValue = value =>
  typeof value === 'string' && value.trim() !== '';
 
/**
 * Validates whether a string is shorter than a given number
 * of characters, ignores empty strings and non-string values
 */
const hasLengthLessThanOrEqual = max => value =>
  hasStringValue(value) === false || value.length <= max;
 
/**
 * Validates whether a string has no spaces,
 * ignores empty strings and non-string values
 */
const hasNoSpaces = value =>
  hasStringValue(value) === false || value.includes(' ') === false;

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:

const validations = [
  {
    field: 'name',
    validation: hasStringValue,
    message: 'Name is required'
  },
  {
    field: 'name',
    validation: hasLengthLessThanOrEqual(80),
    message: 'Maximum 80 characters allowed'
  }
  // All other fields
];

Next, we need to iterate over this array and run validations for all the fields:

function validate(values, validations) {
  const errors = {};
  for (const { field, validation, message } of validations) {
    if (validation(values[field]) === false) {
      errors[field] = message;
    }
  }
  return errors;
}

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!):

const DATE_FORMAT_ISO = 'iso';
const DATE_FORMAT_DE = 'de';
const DATE_FORMAT_UK = 'uk';
const DATE_FORMAT_US = 'us';
 
const getDateFormat = format => {
  const datePart = 'D';
  const monthPart = 'M';
 
  switch (format) {
    case DATE_FORMAT_ISO: {
      return `${monthPart}-${datePart}`;
    }
    case DATE_FORMAT_DE: {
      return `${datePart}.${monthPart}`;
    }
    case DATE_FORMAT_UK: {
      return `${datePart}/${monthPart}`;
    }
    case DATE_FORMAT_US:
    default: {
      return `${monthPart}/${datePart}`;
    }
  }
};

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:

const DATE_FORMATS = {
  [DATE_FORMAT_ISO]: 'M-D',
  [DATE_FORMAT_DE]: 'D.M',
  [DATE_FORMAT_UK]: 'D/M',
  [DATE_FORMAT_US]: 'M/D'
};
 
const getDateFormat = format => {
  return DATE_FORMATS[format] ?? DATE_FORMATS[DATE_FORMAT_US];
};

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

Negative conditions are generally harder to read than positive ones:

if (!enabled) {
  Window.showInformationMessage(
    'ESLint is not running because the deprecated setting eslint.enable is set to false…'
  );
} else {
  Window.showInformationMessage(
    'ESLint is not running. By default only TypeScript and JavaScript files are validated…'
  );
}

Decoding “if not enabled” takes a bit more cognitive effort than “if enabled”:

if (enabled) {
  Window.showInformationMessage(
    'ESLint is not running. By default only TypeScript and JavaScript files are validated…'
  );
} else {
  Window.showInformationMessage(
    'ESLint is not running because the deprecated setting eslint.enable is set to false…'
  );
}

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.

Tip The unicorn/no-negated-condition linter rule automatically converts negative conditions to positive ones.

#Repeated conditions

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:

const isSmall = size => size == '1' || size == '2' || size == '3';

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:

const isSmall = size => ['1', '2', '3'].includes(size);

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:

function getSpecialOffersArray(id, isHornsAndHooves) {
  let specialOffersArray = isHornsAndHooves
    ? Session.get(SPECIAL_OFFERS_CACHE_KEY + '_' + id)
    : Session.get(SPECIAL_OFFERS_CACHE_KEY);
  if (!specialOffersArray) {
    const hornsAndHoovesOffers = getHornsAndHoovesSpecialOffers();
    const pawsAndTailsOffers = getPawsAndTailsSpecialOffers();
    specialOffersArray = isHornsAndHooves
      ? hornsAndHoovesOffers
      : pawsAndTailsOffers;
    Session.set(
      isHornsAndHooves
        ? SPECIAL_OFFERS_CACHE_KEY + '_' + id
        : SPECIAL_OFFERS_CACHE_KEY,
      specialOffersArray
    );
  }
  return specialOffersArray;
}

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:

function getSpecialOffersArray(id, isHornsAndHooves) {
  const cacheKey = isHornsAndHooves
    ? `${SPECIAL_OFFERS_CACHE_KEY}_${id}`
    : SPECIAL_OFFERS_CACHE_KEY;
 
  const cachedOffers = Session.get(cacheKey);
  if (cachedOffers) {
    return cachedOffers;
  }
 
  const offers = isHornsAndHooves
    ? getHornsAndHoovesSpecialOffers()
    : getPawsAndTailsSpecialOffers();
 
  Session.set(cacheKey, offers);
 
  return offers;
}

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.

const getSessionKey = (id, isHornsAndHooves) =>
  isHornsAndHooves
    ? `${SPECIAL_OFFERS_CACHE_KEY}_${id}`
    : SPECIAL_OFFERS_CACHE_KEY;
 
function getSpecialOffersArray(id, isHornsAndHooves) {
  const cacheKey = getSessionKey(id, isHornsAndHooves);
 
  const cachedOffers = Session.get(cacheKey);
  if (cachedOffers) {
    return cachedOffers;
  }
 
  const offers = isHornsAndHooves
    ? getHornsAndHoovesSpecialOffers()
    : getPawsAndTailsSpecialOffers();
  Session.set(cacheKey, offers);
  return offers;
}

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:

const Brand = {
  HornsAndHooves: 'Horns & Hooves',
  PawsAndTails: 'Paws & Tails'
};
 
const getSessionKey = (id, brand) =>
  ({
    [Brand.HornsAndHooves]: `${SPECIAL_OFFERS_CACHE_KEY}_${id}`,
    [Brand.PawsAndTails]: SPECIAL_OFFERS_CACHE_KEY
  })[brand];
 
const getSpecialOffersForBrand = brand =>
  ({
    [Brand.HornsAndHooves]: getHornsAndHoovesSpecialOffers,
    [Brand.PawsAndTails]: getPawsAndTailsSpecialOffers
  })[brand]();
 
function getSpecialOffersArray(id, brand) {
  const cacheKey = getSessionKey(id, brand);
 
  const cachedOffers = Session.get(cacheKey);
  if (cachedOffers) {
    return cachedOffers;
  }
 
  const offers = getSpecialOffersForBrand(brand);
  Session.set(cacheKey, offers);
  return offers;
}

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:

function getDiscountAmount(discountOptions) {
  if (
    discountOptions?.userDiscount?.discountAmount?.displayCurrency
  ) {
    if (
      discountOptions?.promoDiscount?.discountAmount?.displayCurrency
    ) {
      if (
        discountOptions.userDiscount.discountAmount.displayCurrency
          .valueInCents >
        discountOptions?.promoDiscount?.discountAmount
          ?.displayCurrency.valueInCents
      ) {
        return discountOptions?.userDiscount?.discountAmount
          ?.displayCurrency;
      } else {
        return discountOptions?.promoDiscount?.discountAmount
          ?.displayCurrency;
      }
    } else {
      return discountOptions?.userDiscount?.discountAmount
        ?.displayCurrency;
    }
  } else if (
    discountOptions?.promoDiscount?.discountAmount?.displayCurrency
  ) {
    return discountOptions?.promoDiscount?.discountAmount
      ?.displayCurrency;
  }
 
  return { currency: 'EUR', valueInCents: 0 };
}

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:

function getDiscountAmount(discountOptions) {
  const amounts = [
    discountOptions?.userDiscount?.discountAmount?.displayCurrency,
    discountOptions?.promoDiscount?.discountAmount?.displayCurrency
  ];
  const maxAmount = _.maxBy(amounts, amount => amount?.valueInCents);
  return maxAmount ?? { currency: 'EUR', valueInCents: 0 };
}

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.

#Formulas

Similar to tables, a single expression, or a formula can often replace a whole bunch of conditions. Consider this example:

function getStarRating(percentage) {
  if (percentage === 0)
    return '✩✩✩✩✩✩✩✩✩✩';
  if (percentage > 0 && percentage <= 0.1)
    return '★✩✩✩✩✩✩✩✩✩';
  if (percentage > 0.1 && percentage <= 0.2)
    return '★★✩✩✩✩✩✩✩✩';
  if (percentage > 0.2 && percentage <= 0.3)
    return '★★★✩✩✩✩✩✩✩';
  if (percentage > 0.3 && percentage <= 0.4)
    return '★★★★✩✩✩✩✩✩';
  if (percentage > 0.4 && percentage <= 0.5)
    return '★★★★★✩✩✩✩✩';
  if (percentage > 0.5 && percentage <= 0.6)
    return '★★★★★★✩✩✩✩';
  if (percentage > 0.6 && percentage <= 0.7)
    return '★★★★★★★✩✩✩';
  if (percentage > 0.7 && percentage <= 0.8)
    return '★★★★★★★★✩✩';
  if (percentage > 0.8 && percentage <= 0.9)
    return '★★★★★★★★★✩';
  return '★★★★★★★★★★';
}

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:

const FILLED_STAR_ICON = '★';
const EMPTY_STAR_ICON = '✩';
function getStarRating(percentage) {
  const filledStars = Math.ceil(percentage * 10);
  return [
    FILLED_STAR_ICON.repeat(filledStars),
    EMPTY_STAR_ICON.repeat(10 - filledStars)
  ].join('');
}

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.

Info This approach is known as separation of logic and presentation.

#Nested ternaries

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:

const caffeineLevel = 25;
 
let drink;
if (caffeineLevel < 50) {
  drink = 'coffee';
} else {
  drink = 'water';
}
// → 'coffee'

Now, compare it to a ternary:

const caffeineLevel = 25;
const drink = caffeineLevel < 50 ? 'coffee' : 'water';
// → 'coffee'

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.

function Products({products, isError, isLoading}) {
  return isError
    ? <p>Error loading products</p>
    : isLoading
      ? <Loading />
      : products.length > 0
        ? (
            <ul>
              {products.map(product => (
                <li key={product.id}>{product.name}</li>
              ))}
            </ul>
          )
        : <p>No products found</p>
}

This is a rare case where Prettier makes the code completely unreadable:

function Products({ products, isError, isLoading }) {
  return isError ? (
    <p>Error loading products</p>
  ) : isLoading ? (
    <Loading />
  ) : products.length > 0 ? (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  ) : (
    <p>No products found</p>
  );
}

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:

function Products({ products, isError, isLoading }) {
  if (isError) {
    return <p>Error loading products</p>;
  }
 
  if (isLoading) {
    return <Loading />;
  }
 
  if (products.length === 0) {
    return <p>No products found</p>;
  }
 
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

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.

#Complex conditions

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.

Consider this example from the Refactoring Guru:

if (
  platform.toUpperCase().indexOf('MAC') > -1 &&
  browser.toUpperCase().indexOf('IE') > 1 &&
  wasInitialized() &&
  resize > 0
) {
  // Do something…
}

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:

const isMacOs = platform.toUpperCase().includes('MAC');
const isIE = browser.toUpperCase().includes('IE');
const wasResized = resize > 0;
if (isMacOs && isIE && wasInitialized() && wasResized) {
  // Do something…
}

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:

function mapTips(allTips, ingredients, tags) {
  return allTips.filter(tip => {
    return (
      (tip.ingredient === undefined ||
        ingredients.some(({ name }) => name === tip.ingredient)) &&
      tip.tags.every(tag => tags.includes(tag))
    );
  });
}

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:

function mapTips(allTips, ingredients, tags) {
  const ingredientNames = ingredients.map(x => x.name);
 
  return allTips.filter(tip => {
    // The tip’s ingredient matches any of the recipe’s
    // ingredients, if defined
    const hasMatchingIngredients = tip.ingredient
      ? ingredientNames.includes(tip.ingredient)
      : true;
 
    // All tip’s tags are present in recipe’s tags
    const hasMatchingTags = tip.tags.every(tag => tags.includes(tag));
 
    return hasMatchingIngredients && hasMatchingTags;
  });
}

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.
  • Caching repeated conditions in a variable.

Read other sample chapters of the book:

If you have any feedback, drop me a line at artem@sapegin.ru, @sapegin@mastodon.cloud, @sapegin.me, or open an issue.

Get the book now!

Washing your code book cover