5 Ways to Reduce Excessive If Statements in TypeScript

5 Ways to Reduce Excessive If Statements in TypeScript

Sunny Sun Lv4

If statements, while a fundamental programming construct, can often lead to code that is difficult to read, maintain, and test. Nesting multiple if conditions can create a tangled web of logic, making it challenging to understand the program’s flow. Moreover, as codebases grow, managing these conditional statements becomes increasingly complex. To enhance code readability and maintainability, TypeScript offers several alternatives that can streamline your conditional logic.

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

Functional Programming with Higher-Order Functions

Functional programming emphasizes pure functions and immutability, leading to cleaner, more predictable code. Higher-order functions, which are functions that take other functions as arguments or return functions as results, are central to this paradigm. Let’s explore how to apply this approach to a common problem.

Problem:
Given an array of numbers, calculate the sum of all even numbers.

1
2
3
4
5
6
7
8
9
10
function sumEvenNumbers(numbers: number[]): number {
let sum = 0;
for (const num of numbers) {
if (num % 2 === 0) {
sum += num;
}
}
return sum;
}

The code is less declarative and harder to understand at a glance.
Solution

1
2
3
4
5
6
function sumEvenNumbersFunctional(numbers: number[]): number {
return numbers
.filter(num => num % 2 === 0)
.reduce((acc, num) => acc + num, 0);
}

After the refactoring, the code is more concise and easier to understand.

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}`;
  } 

Use 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.

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.

Extract Method

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.

Conclusion

Excessive if-else statements often indicate deeper underlying issues in code structure. Rather than merely addressing the symptoms, effective refactoring requires identifying the root cause. Functions encapsulate inputs, processing logic, and outputs. Overreliance on if-else statements might signify complex logic that could be simplified through techniques like polymorphism, functional programming, or design pattern application.
I hope you find this post useful.

  • Title: 5 Ways to Reduce Excessive If Statements in TypeScript
  • Author: Sunny Sun
  • Created at : 2024-08-19 00:00:00
  • Updated at : 2024-08-16 19:46:17
  • Link: http://coffeethinkcode.com/2024/08/19/five-ways-to-avoid-if-in-typescript/
  • License: This work is licensed under CC BY-NC-SA 4.0.