Master the Art of Refactoring: 10 Techniques for Building Maintainable Code

Discover 10 powerful refactoring techniques to level up your front-end coding skills.

#javascript
#typescript
#refactoring
#bestpractices
Master the Art of Refactoring: 10 Techniques for Building Maintainable Code
Picture by Ash

Writing code is like painting a picture. You want to create something beautiful and functional that will stand the test of time. But just like a painting can become faded and discolored over time, so can your code. And that's where refactoring comes in - it's like giving your code a fresh coat of paint!

Good code is not just about solving problems, it's about creating code that is elegant, structured, and easy to read. Code that is easy to read is a lifesaver in the long run - it saves you and your team time and headaches when you need to make changes or fix bugs.

But here's the thing - even the best code can become messy and hard to read over time. And that's why you need to learn about the art of refactoring. Refactoring is like a magic wand that can turn your tangled code into a thing of beauty without changing its functionality.

In a previous blog post, I introduced you to 3 fundamental refactoring techniques. But why stop there? There are so many more techniques that can help you keep your codebase clean and organized. So let's dive in and discover 10 more refactoring practices that will take your code to the next level! 🚀

Decompose Conditional

Imagine you're trying to follow directions to a new restaurant but the directions are a mess, full of confusing turns and unclear landmarks. It's frustrating and time-consuming, right? The same thing can happen with code when a conditional statement becomes a convoluted maze. But don't worry, there's a solution! Enter the Decompose Conditional refactoring technique. It's like taking those messy directions and breaking them down into smaller, more digestible steps. By breaking down complex conditional statements into simpler ones, you can make your code easier to follow and maintain.

Let's say we have the following code:

ts
if (temperature > 30 && isSummer) {
console.log("It's too hot outside!");
}

We can refactor this code as follows

ts
if (isTooHot(temperature, isSummer)) {
console.log("It's too hot outside!");
}

function isTooHot(temperature: number, isSummer: boolean): boolean {
return temperature > 30 && isSummer;
}

Move Statements into Function

Have you ever found yourself copying and pasting the same block of code in different parts of your program? Not only is it tedious and time-consuming, but it can also lead to maintenance issues down the road. That's where the Move Statements into Function refactoring technique comes in! By creating a separate function for that block of code, you can reduce duplication and make your code more organized and easy to maintain.

So, next time you find yourself repeating code, don't copy and paste - refactor it! Here is a simple example of how you can apply this technique:

ts
function calculateTotalPrice(price: number, quantity: number, hasDoubleDiscount: boolean): number {
let total = price * quantity;
if (total > 100) {
total = applyDiscount(total); // Total: total * 0.9
}
if (hasDoubleDiscount) {
total = applyDiscount(total); // Total: total * 0.9 * 0.9
}
return total;
}

function applyDiscount(total: number): number {
return total * 0.9;
}

Remove Flag Argument

How often, looking at a piece of code with a flag argument, you thought "What is this doing"? Well, you're not alone. Flag arguments can make code confusing and difficult to maintain. But don't worry, there's a solution! With the "Remove Flag Argument" refactoring technique, we can break up the code into separate functions for each behavior the flag controls:

ts
// 😥 Ouch
function bookFlight(customerName: string, isFirstClass: boolean) {
if (isFirstClass) {
// Logic for first class
} else {
// Logic for regular booking
}
}

// 🤩 Better
function bookFirstClassFlight(customerName: string) {
// Logic for first class
}

function bookFlight(customerName: string) {
// Logic for regular booking
}

Replace Command with Function

Sometimes, using a command to perform a specific task in your code can be like trying to kill a fly with a sledgehammer. In such cases, it's better to opt for a simpler and more elegant solution. The "Replace Command with Function" refactoring technique can help you achieve this. By turning a command that operates on an object into a function that returns a value, you can avoid the unnecessary trouble that a command object brings in.

ts
// Before
class Car {
start() {
// Do something to start the car
}

stop() {
// Do something to stop the car
}
}

const myCar = new Car();
myCar.start();
myCar.stop();

// After
function startCar(car: Car) {
// Do something to start the car
}

function stopCar(car: Car) {
// Do something to stop the car
}

const myCar = new Car();
startCar(myCar);
stopCar(myCar);

Replace Primitive with Object

When we have a variable that is used in multiple places and carries multiple properties, it may be more beneficial to create a new object to store those properties rather than using the primitive data type directly. This can also help us to add new functionality to the object in the future without breaking the existing code.

Suppose we have the code:

ts
function calculatePrice(quantity: number, pricePerUnit: number) {
return quantity * pricePerUnit;
}

We are using two primitive types, quantity and pricePerUnit, to calculate the price of a product. However, we can refactor the code to create a new object called Product to store these properties.

ts
class Product {
constructor(public quantity: number, public pricePerUnit: number) {}

getPrice(): number {
return this.quantity * this.pricePerUnit;
}
}

const product = new Product(5, 10);
console.log(product.getPrice()); // 50

Replace Nested Conditionals with Guard Clause

Have you ever seen code that has multiple nested conditionals that make it hard to read and understand? It can be a nightmare to maintain and debug! Luckily, there is a way to make the code more readable and maintainable by using guard clauses.

Guard clauses are early-return statements that simplify code by checking for error conditions at the beginning of a function or method. They help handle error conditions and avoid nested conditionals.

For instance, to check if a user is eligible for a discount, we can use guard clauses to check if the user is a premium member or has made a minimum number of purchases required for the discount.

ts
// Before, using nested conditionals 😥
function calculateDiscount(user: User) {
let discount;
if (user.isPremium) {
// Calculate discount for premium members
discount = applyPremiumDiscount(user);
} else {
if (user.purchases >= 10) {
// Calculate discount for non-premium members with 10 or more purchases
discount = applyFidelityDiscount(user);
} else {
// No discount for non-premium members with less than 10 purchases
discount = 0;
}
}

return discount;
}

// After, using guards 🤓
function calculateDiscount(user: User) {
if (user.isPremium) return applyPremiumDiscount(user);
if (user.purchases >= 10) return applyFidelityDiscount(user);

return 0;
}

Now can easily see the three possible outcomes of the function: a discount for premium members, a discount for non-premium members with 10 or more purchases, and no discount for non-premium members with less than 10 purchases.

Parameterize Function

This practice is used when a function has hardcoded values or constants, which makes it less flexible and reusable. This technique involves creating parameters for those hardcoded values or constants, making the function more versatile and allowing it to be used in various scenarios.

For example, suppose you have a function that calculates the price of a product, but it uses a hardcoded tax rate. If the tax rate changes or if you want to calculate the price without tax, you will need to create a new function. With the "Parameterize Function" technique, you can add a parameter to the function for the tax rate, allowing it to be used in different scenarios without the need for a new function.

ts
// Before
function calculateProductPrice(productPrice: number) {
const taxRate = 0.2; // Hardcoded tax rate
return productPrice + productPrice * taxRate;
}

// After
function calculateProductPrice(productPrice: number, taxRate: number = 0.2) {
return productPrice + productPrice * taxRate;
}

Split Loop

Have you ever come across a loop in your code that performs multiple tasks? It can be challenging to read and maintain, right? That's where the Split Loop refactoring technique comes in handy!

This technique involves breaking down a loop that performs multiple tasks into smaller, more specific loops that handle each task separately. By doing so, you can improve the readability of the code and make it more maintainable. Plus, you can easily extend the code in the future by adding or modifying individual loops as needed.

ts
// Before
const names = [];
const ages = [];

for (const person of people) {
names.push(person.name);
ages.push(person.age);
}

// After split loop and use the array method .map()
const names = people.map(person => person.name);
const ages = people.map(person => person.age);

Preserve Whole Object

Are you tired of functions with too many parameters? Then the Preserve Whole Object refactoring technique is here to help! This technique allows you to pass in an object instead of a long list of individual parameters.

Let's say you have a function that calculates the area of a rectangle, and it takes in the length and width of the rectangle as parameters. Instead of passing in these parameters individually, you can create an object that contains all the necessary information, like this:

ts
// Before
function calculateArea(length: number, width: number): number {
return length * width;
}

// After
type Rectangle = {
length: number;
width: number;
};

function calculateArea(rect: Rectangle): number {
return rect.length * rect.width;
}

By passing in the Rectangle object, you can avoid passing in individual parameters and make your code more concise and readable. Plus, if you need to add or remove properties in the future, you can do so without having to modify the function's signature.

Encapsulate Collection

This is a refactoring technique that involves wrapping a collection of related objects within a class and hiding the collection from external access. This is done by exposing only the necessary methods to manipulate the collection, rather than allowing direct access to the collection itself.

Let's consider an example where we have a ShoppingCart class. Initially, we have multiple methods that encapsulate the list by directly accessing and modifying the array:

ts
class ShoppingCart {
constructor() {
this.items = [];
}

get cartProducts() {
return this.items;
}

set cartProducts(products: Product[]) {
this.items = products;
}
}

As the codebase grows, it becomes challenging to manage the array of users, as multiple parts of the code are directly accessing and modifying it.

Instead, you can provide add, remove, and access methods to interact with the collection, rather than exposing the collection directly. Additionally, if a list needs to be returned, it's recommended to return a copy of the collection or a read-only pointer, depending on the language.

ts
class ShoppingCart {
constructor() {
this.items = [];
}

public addProduct(product: Product) {
this.items.push(item);
}

public removeProducts(item: string) {
// Logic for removing the product
}
}

Wrapping up

Refactoring is like giving your code a makeover, making it more organized, attractive, and easy to maintain. By using these 10 refactoring techniques, you can transform your code into something beautiful.

These techniques will help you to identify areas of your code that need improvement and make the necessary changes to improve its readability and performance.

It's important to remember that refactoring is not a one-time event but an ongoing process, so don't try to do everything at once. Instead, start with small steps and gradually improve your code over time.

Happy refactoring! 🚀

Last updated: