Reduce Cognitive Complexity In Forms.ts

Alex Johnson
-
Reduce Cognitive Complexity In Forms.ts

Cognitive Complexity is a measure of how difficult it is to understand a piece of code. High cognitive complexity can lead to increased maintenance costs, higher defect rates, and decreased developer productivity. SonarCloud's rule typescript:S3776 flags functions with excessive cognitive complexity, urging developers to simplify their code.

This article addresses the challenge of reducing the cognitive complexity of a function in backend/core-api/src/modules/forms/db/models/Forms.ts, specifically at line L189, where the complexity is currently at 31, exceeding the allowed limit of 15. We'll explore several strategies to refactor the function and bring its complexity within acceptable bounds.

Understanding the Problem

Before diving into solutions, it's crucial to understand why cognitive complexity matters. Complex code is harder to read, understand, and debug. When a function performs too many tasks or has intricate control flow, developers spend more time deciphering its logic, increasing the risk of errors and making future modifications challenging. Therefore, refactoring to reduce cognitive complexity is an investment in code maintainability and overall project health. Guys, it's like trying to navigate a maze – the simpler the path, the easier it is to reach the end!

Strategies for Reducing Cognitive Complexity

Several techniques can be employed to reduce cognitive complexity. Let's examine some of the most effective approaches:

1. Extract Complex Conditions into New Functions

Complex conditional statements are a major contributor to cognitive complexity. When a condition involves multiple operators and sub-expressions, it becomes difficult to grasp the overall logic at a glance. Extracting such conditions into separate, well-named functions improves readability and reduces complexity.

Example:

function calculateFinalPrice(user, cart) {
 let total = calculateTotal(cart);
 if (
 user.hasMembership && // +1 (if)
 user.orders > 10 && // +1 (more than one condition)
 user.accountActive &&
 !user.hasDiscount || // +1 (change of operator in condition)
 user.orders === 1
 ) {
 total = applyDiscount(user, total);
 }
 return total;
}

Refactored:

function calculateFinalPrice(user, cart) {
 let total = calculateTotal(cart);
 if (isEligibleForDiscount(user)) { // +1 (if)
 total = applyDiscount(user, total);
 }
 return total;
}

function isEligibleForDiscount(user) {
 return (
 user.hasMembership &&
 user.orders > 10 && // +1 (more than one condition)
 user.accountActive &&
 !user.hasDiscount || // +1 (change of operator in condition)
 user.orders === 1
 );
}

In this example, the complex condition is extracted into the isEligibleForDiscount function. While the overall complexity might not change drastically, the calculateFinalPrice function becomes much easier to understand. It now focuses on the core logic of calculating the final price, delegating the discount eligibility check to another function. This principle of separation of concerns is fundamental to writing clean and maintainable code. The function isEligibleForDiscount is now responsible for handling the intricate logic related to discount eligibility, which helps to keep the main function, calculateFinalPrice, focused and easier to understand. This modular approach improves code readability and maintainability, making it easier to identify and fix issues or update the eligibility criteria in the future.

2. Break Down Large Functions

Large functions that perform multiple tasks are inherently complex. Breaking them down into smaller, single-purpose functions improves code organization and reduces cognitive load. Each function should have a clear and well-defined responsibility.

Example:

function calculateTotal(cart) {
 let total = 0;
 for (let i = 0; i < cart.length; i++) { // +1 (for)
 total += cart[i].price;
 }

 // calculateSalesTax
 for (let i = 0; i < cart.length; i++) { // +1 (for)
 total += 0.2 * cart[i].price;
 }

 // calculateShipping
 total += 5 * cart.length;

 return total;
}

Refactored:

function calculateTotal(cart) {
 let total = calculateSubtotal(cart);
 total += calculateSalesTax(cart);
 total += calculateShipping(cart);
 return total;
}

function calculateSubtotal(cart) {
 let subTotal = 0;
 for (const item of cart) { // +1 (for)
 subTotal += item.price;
 }
 return subTotal;
}

function calculateSalesTax(cart) {
 let salesTax = 0;
 for (const item of cart) { // +1 (for)
 salesTax += 0.2 * item.price;
 }
 return salesTax;
}

function calculateShipping(cart) {
 return 5 * cart.length;
}

By breaking down the calculateTotal function into calculateSubtotal, calculateSalesTax, and calculateShipping, we've created a more modular and understandable structure. Each function now has a specific responsibility, making it easier to reason about and test. This approach aligns with the Single Responsibility Principle, a key concept in software design. Refactoring the calculateTotal function not only reduces its cognitive complexity but also promotes code reusability. If, for example, the logic for calculating sales tax needs to be used elsewhere in the application, the calculateSalesTax function can be easily called without duplicating code. Similarly, the separation of concerns makes it easier to modify or extend the functionality of each component without affecting the others.

3. Avoid Deep Nesting by Returning Early

Deeply nested conditional statements can significantly increase cognitive complexity. To avoid nesting, process exceptional cases first and return early. This flattens the control flow and makes the code easier to follow.

Example:

function calculateDiscount(price, user) {
 if (isEligibleForDiscount(user)) { // +1 (if)
 if (user?.hasMembership) { // +2 ( nested if )
 return price * 0.9;
 } else if (user?.orders === 1) { // +1 ( else )
 return price * 0.95;
 } else { // +1 ( else )
 return price;
 }
 } else { // +1 ( else )
 return price;
 }
}

Refactored:

function calculateDiscount(price, user) {
 if (!isEligibleForDiscount(user)) { // +1 ( if )
 return price;
 }
 if (user?.hasMembership) { // +1 ( if )
 return price * 0.9;
 }
 if (user?.orders === 1) { // +1 ( if )
 return price * 0.95;
 }
 return price;
}

By checking for the ineligible case first and returning early, we've eliminated the deep nesting and simplified the control flow. The refactored code is more readable and easier to understand. This technique of "return early" is a simple but powerful way to reduce cognitive complexity, especially in functions with multiple conditional branches. It can also lead to improved performance, as the function avoids unnecessary computations when the input doesn't meet certain criteria. By prioritizing the handling of edge cases or invalid inputs at the beginning of the function, you can ensure that the main logic is executed only when appropriate, making the code more efficient and robust.

4. Use Null-Safe Operations (Optional Chaining)

When accessing nested properties, it's common to perform multiple null checks to avoid errors. Null-safe operations, such as the optional chaining operator (?.), can simplify this process and reduce cognitive complexity.

Example:

let manufacturerName = null;

if (product && product.details && product.details.manufacturer) { // +1 (if) +1 (multiple condition)
 manufacturerName = product.details.manufacturer.name;
}
if (manufacturerName) { // +1 (if)
 console.log(manufacturerName);
} else {
 console.log('Manufacturer name not found');
}

Refactored:

let manufacturerName = product?.details?.manufacturer?.name;

if (manufacturerName) { // +1 (if)
 console.log(manufacturerName);
} else {
 console.log('Manufacturer name not found');
}

The optional chaining operator eliminates the need for multiple null checks, making the code more concise and easier to read. It gracefully handles cases where any of the nested properties are null or undefined, returning undefined without throwing an error. This not only reduces cognitive complexity but also improves the robustness of the code. The optional chaining operator is a valuable tool for simplifying code that involves accessing potentially nullable properties, especially in complex data structures. It makes the code more readable and reduces the risk of runtime errors caused by unexpected null or undefined values.

Applying the Strategies to Forms.ts (Line L189)

To effectively refactor the function at line L189 in backend/core-api/src/modules/forms/db/models/Forms.ts, we need to analyze the specific code and identify the sources of cognitive complexity. Based on the strategies discussed above, we can then apply the appropriate refactoring techniques. Remember, a combination of these strategies might be necessary to achieve the desired complexity reduction.

  1. Analyze the Code: Carefully examine the function's logic, paying attention to complex conditional statements, deeply nested blocks, and large sections of code. Identify the specific parts that contribute most to the cognitive complexity.
  2. Extract Complex Conditions: If the function contains complex conditional statements, extract them into separate, well-named functions. This will improve readability and reduce the complexity of the main function.
  3. Break Down the Function: If the function performs multiple tasks, break it down into smaller, single-purpose functions. This will improve code organization and make it easier to reason about each part of the code.
  4. Avoid Deep Nesting: If the function contains deeply nested blocks, try to flatten the control flow by returning early or using other techniques to reduce nesting.
  5. Use Null-Safe Operations: If the function accesses nested properties, use the optional chaining operator to simplify the code and avoid null-related errors.

Pitfalls to Avoid

Refactoring complex code can be risky, so it's essential to take precautions to avoid introducing new errors. Before making any changes, ensure that you have comprehensive unit tests that cover the code. After refactoring, run the tests to verify that the code still works as expected.

Conclusion

Reducing cognitive complexity is crucial for maintaining healthy and sustainable codebases. By applying the strategies discussed in this article, you can effectively refactor complex functions and improve code readability, maintainability, and overall quality. Remember to analyze the code carefully, apply the appropriate refactoring techniques, and test thoroughly to ensure that the changes don't introduce new errors. Alright guys, that's all for now! Happy coding!

For more information on cognitive complexity and code quality, check out SonarSource.

You may also like