4 Simple and Effective Ways To Avoid Too Many Ifs With TypeScript

4 Simple and Effective Ways To Avoid Too Many Ifs With TypeScript

Sunny Sun Lv4

If…else is not bad — excessive usage is

If…else exists in all programming languages. They’re not bad practices if used properly. It’s a simple, easy-to-understand, and flexible logic control structure.

But in reality, it’s often used excessively. They can be bad in the following cases:

  • Nested if-else or multiple-level nesting (worse)

  • Too many if-else cause large numbers of condition branches

  • Complex condition statement with mixed flags

The excessive usages of if…else are code smells. It makes your codebase hard to read and maintain, as there are more branching paths to consider.

As a developer, we always look to improve the code by refactoring . In this article, we will go through four different approaches in the context of TypeScript.

1. Guard and Early Return

Problem: In this nested if…else code snippet, it can be challenging to follow the changes to the status value. Even in this simplified example, the control flow is not straightforward. You can imagine the code quickly growing into a hard-to-maintain state when more condition logic is added.


  getStatus(response: { status: string, error: any }) {
    let status = '';
    if (response.status === '200') {
      this.process();
      status = 'success';
    } else {
      if (response.error) {
        status = response.error.message;
      } else {
        status =  'unexpected error';
      }
    }
    return status;
  }

The solution: Guard and early return is my favorite refactoring method. It’s simple to implement and gives you quick and immediate results.

In the code below, we move the edge cases to the beginning of the function. We also simplified the nested if into a flat statement with a ternary operator. When the edge case condition is met, the error status is immediately returned.


   getStatus(response: { status: string, error: any }) {
    if (response.status !== '200') {
      return response.error ? response.error.message : 'unexpected error';
    }
    this.process();
    return 'success';
  } 

After refactoring, the code becomes linear. The early return handles failure cases first, making the code easier to test and less error-prone. The result is better readability and better maintainability.

Another similar way is to use the “Data guard” decorator which contains the validation logic. The main function is only invoked after the validation passing.

2. Table-Driven Method

Problem: The intention of the code snippet below is to get the number of days for a month. Obviously, it’s error-prone and hard to change. For example, what happens if we need support in leap years? It will be a maintenance nightmare.


     if (month === 1) return 31;
    if (month === 2) return 29;
    if (month === 3) return 31;
    if (month === 4) return 30;
    if (month === 5) return 31;
    if (month === 6) return 30;
    if (month === 7) return 31;
    if (month === 8) return 31;
    if (month === 9) return 30;
    if (month === 10) return 31;
    if (month === 11) return 30;
    if (month === 12) return 31; 

The solution: We use the Table-driven method to improve the above code snippet.

Table-driven methods are schemes that allow you to look up information in a table rather than using logic statements (i.e. case, if). — dev.to


     const monthDays= [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    return monthDays[month - 1]; 

Here we define an array monthDays that maps the possible values of a month. The result is much more compact and readable. Please note that this method is more suitable when input parameters are mutually exclusive.

The same method can be applied to a group of functions.


 // many ifs
function performActions(actionName: string) {
  if (actionName === "save") {
    // save...
  } else if (actionName === "update") {
    // update ...
  } else if (actionName === "delete") {
    // delete...
  }
}

// refactor with table driven methods
const funcs: Record void> = {
  save: () => {
    // save ...
  },
  update: () => {
    // update ...
  },
  delete: () => {
    //delete ...
  }
};

function performActions2(actionName: string) {
  const action = funcs[actionName];
  if (action) {
    action();
  }
}
 

In the above code sample, the performAction function contains multiple ifs. It can be difficult to extend when more actions need to be added into. We use the funcs RecordType to map the actionName matching function. Thus we avoid the multiple ifs, and it can be extended to additional actions easily.

The table-driven method can be applied beyond just one-to-one key-value mappings. In the following example, the ifs statement is used to rating by a given score range. How can we refactor this method?

  getRating(score: number) {
    if (score > 12) {
      return 5;
    } else if (score > 9) {
      return 4;
    } else if (score > 6) {
      return 3;
    } else if (score > 3) {
      return 2;
    } else {
      return 1;
    }
  }

we can apply the Table-driven method using Object.keys as below:

  getRating(score: number) {
    const ratingScoreMap = {
      12: 5,
      9: 4,
      6: 3,
      3: 2,
      1: 1
    };
    const sortedRating = Object.keys(ratingScoreMap)
      .map(Number)
      .sort((a, b) => b - a);
    for (const threshold of sortedRating) {
      if (score > Number(threshold)) {
        return ratingScoreMap[threshold];
      }
    }
    return 1;
  }

By using a lookup table to store the mapping relationships, the table-driven method allows for efficient and flexible value mapping in a variety of contexts.

3. Extract

Problem: Complex conditions aren’t a problem as long as they are readable. The below example contains a nested and complex if condition statement. It’s hard to figure out what the condition is.


    transfer(fromAccount, toAccount, amount: number) {
    if (fromAccount.status === 'Active' && toAccount.status === 'Active' && amount > 0) {
      if (amount < fromAccount.dailyLimit && amount < fromAccount.amount) {
        fromAccount.amout -= amount;
        fromAccount.transactions.add(amount, 'debit');
        toAccount.amout += amount;
        toAccount.transactions.add(amount, 'credit');
      }
      this.logTransaction();
    }
  }

Solution: The extract method is a way to refactor conditional logic into a more modular form. In the code snippet below, we use the extract method to refactor the above example.

The multiple checks in the nested ifs are wrapped into a separate function with a meaningful name. The nested ifs are replaced by a single if with a simple condition function.


   transfer(fromAccount, toAccount, amount: number) {
    if(this.validateTransactoin(fromAccount, toAccount, amount)){
      this.performTransaction();
    }
    this.logTransaction();
  } 

The improved version is much easier to understand and change.

4. Default Value and || operators

Problem: The following if..else is used to perform the null checks, but the code is unnecessarily complex and not very readable.


   getStatusMessage(status: string, errorCode: number) {
    if (!status) return 'NA';
    if(errorCode) {
      return `Status:${status}, err:{errorCode}`;
    } else {
      return `Status:${status}, err: -1`;
    }
  } 

The solution: Giving parameters a default value is another of my personal favorite refactoring methods.

Default value combined with the || operator is a simple and effective way of reducing code complexity.

In this example, we give the status a default value and use the || operator to handle the null check. Thanks to the flexibility of JavaScript, the result is less code duplication and more readable code.


   getStatusMessage(status = 'NA', errorCode: number) {
    return `Status:${status}, err: ${errorCode || -1}`;
  } 

Focus on the Root Cause

Excessive usage of if…else can be a symptom of other issues. When working on refactoring, we should focus on the root cause instead of just fixing the symptom.

A function is composed of input, internal state, and output. The function takes the input parameters, executes the internal state mutation, and returns the output result. The excessive usage of if..else can be the result of:

  • Excessive logic for the internal state: All the above four methods may be applied to improve the code.

  • Complex input parameters with mixed flags: In this case, the default value method may help. But the root cause may be the function interface abstraction. We may need to review and improve the abstraction.

  • Too many different return paths and complex output: It’s a sign that the function may be doing too many things. The focus should be refactoring the function instead of reducing the if..else statements.

To do a clean refactoring, we should focus on formulating a clear interface so that the function is doing one single thing. (Single responsibility principle .

Summary

We discussed four simple and effective ways to remove the excessive usage of if…else. Applying these methods will help you write cleaner, more readable code.

There are other approaches to refactor the if..else, like using a factory pattern or responsibility chain pattern. They are not covered in this article because I want to focus on simpler and more practical solutions.

There is no one size fits all solution as every approach has its own pros and cons. The ability to pick the right tool for a particular job separates a top developer from an average one.

If you like this article, you may also like to read another TypeScript article.
Apply Builder Pattern To Generate Query Filter In TypeScript
Implement an immutable and strongly typed filter builderbetterprogramming.pub

Happy programming!

  • Title: 4 Simple and Effective Ways To Avoid Too Many Ifs With TypeScript
  • Author: Sunny Sun
  • Created at : 2021-09-21 00:00:00
  • Updated at : 2024-07-07 11:38:07
  • Link: http://coffeethinkcode.com/2021/09/21/4-simple-and-effective-ways-to-avoid-too-many-ifs-with-typescript/
  • License: This work is licensed under CC BY-NC-SA 4.0.
On this page
4 Simple and Effective Ways To Avoid Too Many Ifs With TypeScript