Reduce the number of if-else and switch-case conditional clauses

eye-catchJavaScript/TypeScript

Have you found a function is not readable because of lots of conditional clauses such as if-else or switch-case? It’s time to refactor then.

This article is for those people who found such a code and want to improve it but don’t have a good idea.

Sponsored links

Abuse of Conditional clauses

I guess many developers have written the following type of code.

interface InputArgs {
    text: string;
}

const obj1 = { text: "C" };
const obj2 = { text: "H" };

function getCorrespondingData(args: InputArgs): number {
    if (args.text === "A") { return 1; }
    else if (args.text === "B") { return 2; }
    else if (args.text === "C") { return 3; }
    else if (args.text === "D") { return 4; }
    else if (args.text === "E") { return 5; }
    else if (args.text === "F") { return 6; }
    else return -1;
}

console.log(getCorrespondingData(obj1)); // 3
console.log(getCorrespondingData(obj2)); // -1

There are many possible input values and you need to convert it to another type. In this example, the input is string and the output is number but it could be an object or class in a real case.

There are only 6 cases in the example above but it can get bigger as the software grows. As a result, the function gets less readable.

We want to make it readable. This is the topic of this article.

Using Dictionary like object

In TypeScript, we can use Map object. It can store key-value pairs. Since we convert a string to a number, the key is string and the value is number.

function getCorrespondingData2(args: InputArgs): number {
    const map = new Map([
        ["A", 1],
        ["B", 2],
        ["C", 3],
        ["D", 4],
        ["E", 5],
        ["F", 6],
    ]);
    return map.get(args.text) ?? -1;
}

The code above for the Map definition is the same as the following.

const map2 = new Map();
map2.set("A", 1);
map2.set("B", 2);
map2.set("C", 3);
map2.set("D", 4);
map2.set("E", 5);
map2.set("F", 6);

The last statement tries to get a corresponding value but it can be undefined. Therefore, we need to pass a default value in this case. ?? means that the left side returns undefined, it returns the value of the right side. If it receives “H”, it returns -1.

This implementation is simple and easy to use. But this can be used only if the condition is simple enough like string/number comparison case. If we need to compare two things, this technique can’t be applied.

Apply Strategy Pattern

If you don’t know about this name, you might not know either other pattern. I recommend you check Design Patterns. The following book is recommended to learn it. You will find other nice patterns as well. if you know the names, you can search for them when you need them.

Let’s create a class for each logic first.

interface Linker {
    getData(): number;
}

class ALinker implements Linker {
    getData(): number {
        return 1;
    }
}

// BLinker, CLinker ... here

class FLinker implements Linker {
    getData(): number {
        return 6;
    }
}
class NoLinker implements Linker {
    getData(): number {
        return -1;
    }
}

function getCorrespondingData3(args: InputArgs): number {
    const linkerMap: Map<string, Linker> = new Map([
        ["A", new ALinker()],
        ["B", new BLinker()],
        ["C", new CLinker()],
        ["D", new DLinker()],
        ["E", new ELinker()],
        ["F", new FLinker()],
    ]);
    const linker = linkerMap.get(args.text) ?? new NoLinker();
    return linker.getData();
}

The key is to create an interface. Linker interface defines the function that is called in the actual function code. All classes implement the interface, so we can push all classes into a Map object. This is basically the same as the previous way.

It receives input and gets the corresponding class and call the function. You might think the previous way, which is using a Map with literal type, is better because of the Conciseness. That’s true for this simple example.

However, we can extend it if necessary. The map object worked as a conditional clause but let’s move it to the interface.

interface Linker {
    getData(): number;
}

interface StringLinker extends Linker {
    fulfilled(args: InputArgs): boolean;
}

class ALinker implements StringLinker {
    fulfilled(args: InputArgs): boolean {
        return args.text === "A";
    }
    getData(): number {
        return 1;
    }
}

// BLinker, CLinker ... here

class FLinker implements StringLinker {
    fulfilled(args: InputArgs): boolean {
        return args.text === "F";
    }
    getData(): number {
        return 6;
    }
}
class NoLinker implements Linker {
    getData(): number {
        return -1;
    }
}

function getCorrespondingData4(args: InputArgs): number {
    const linkers = [
        new ALinker(),
        new BLinker(),
        new CLinker(),
        new DLinker(),
        new ELinker(),
        new FLinker(),
    ];
    for (const linker of linkers) {
        if (linker.fulfilled(args)) {
            return linker.getData();
        }
    }
    return new NoLinker().getData();
}

We defined StringLinker interface that has fulfilled function used for the condition check. Each class has its own condition. If we call the function for the condition check, we don’t need many if-else clauses.

In this way, we can write a complex condition in each class when the InputArgs has more than 2 properties.

Do not define error handling in the base class

I didn’t write in the following way.

class NoLinker {
    public fulfilled(args: InputArgs): boolean {
        return true;
    }
    public getData(): number{
        return -1;
    }
}

class ALiker extends NoLinker {
    public fulfilled(args: InputArgs): boolean {
        return args.text === "A";
    }
    public getData(): number{
        return 1;
    }
}

// Function lacks ending return statement and return type does not include 'undefined'
function getCorrespondingData4(args: InputArgs): number {
    const linkers = [
        new ALinker(),
        new BLinker(),
        new CLinker(),
        new DLinker(),
        new ELinker(),
        new FLinker(),
        new NoLinker(),
    ];
    for (const linker of linkers) {
        if (linker.fulfilled(args)) {
            return linker.getData();
        }
    }
}

Don’t do this. ALinker is not NoLinker. It is definitely different. In addition to that, the compiler shows an error because the compiler doesn’t know that NoLinker.fulfilled returns true. Therefore, it thinks if-statement could not be fulfilled and the function doesn’t return anything.

If you really want to define the default value in the base class, it should look like the following.

class DefaultLinker {
    public fulfilled(args: InputArgs): boolean {
        return true;
    }
    public getData(): number{
        return -1;
    }
}

class ALiker extends DefaultLinker {
    public fulfilled(args: InputArgs): boolean {
        return args.text === "A";
    }
    public getData(): number{
        return 1;
    }
}

function getCorrespondingData4(args: InputArgs): number {
    const linkers = [
        new ALinker(),
        new BLinker(),
        new CLinker(),
        new DLinker(),
        new ELinker(),
        new FLinker(),
    ];
    for (const linker of linkers) {
        if (linker.fulfilled(args)) {
            return linker.getData();
        }
    }
    const defaultLinker = new DefaultLinker();
    return defaultLinker.getData();
}

But ALinker is still not DefaultLinker. This is not a good design.

Apply Chain of Responsibility Pattern

There is another pattern that we can apply in this situation. It’s Chain of Responsibility. A class receives the input and if it can’t handle it, the class passes the input to the next class. This is similar to the last implementation. It checks with fulfilled function and if it returns false, the next class tries to resolve it.

But in this pattern, we don’t have any conditional case in the top-level function because the handling is done under the hood. Let’s see the actual code.

abstract class LinkerBase {
    private next?: LinkerBase;

    public setNext(next: LinkerBase): LinkerBase {
        this.next = next;
        return next;
    }

    public getData(args: InputArgs): number {
        if (this.fulfilled(args)) {
            return this.getValue();
        } else if (this.next) {
            return this.next.getData(args);
        }
        return -1;
    }
    protected abstract getValue(): number;
    protected abstract fulfilled(args: InputArgs): boolean;
}

This is the base class. If the current class can handle it fulfilled function returns true and a client can get the corresponding value from getValue. If it can’t handle it and it has the next class, it passes the input to the next class. If the program reaches the end of the chain, it returns -1. It is the default value in this case.

Look at the return type of setNext function. It returns its own type. It lets us chain the function call and set the next with less code.

class ALinkerEx extends LinkerBase {
    protected getValue(): number {
        return 1;
    }
    protected fulfilled(args: InputArgs): boolean {
        return args.text === "A";
    };
}

// BLinkerEx, CLinkerEx ... here

class FLinkerEx extends LinkerBase {
    protected getValue(): number {
        return 6;
    }
    protected fulfilled(args: InputArgs): boolean {
        return args.text === "F";
    };
}

function getCorrespondingData5(args: InputArgs): number {
    const linker = new ALinkerEx();
    linker.setNext(new BLinkerEx())
        .setNext(new CLinkerEx())
        .setNext(new DLinkerEx())
        .setNext(new ELinkerEx())
        .setNext(new FLinkerEx());

    return linker.getData(args);
}

console.log(getCorrespondingData5({ text: "D" })) // 4
console.log(getCorrespondingData5({ text: "H" })) // -1

As I mentioned, there is no conditional clause in the function. No if-else, switch-case, and loop. The low-level logic is hidden and it got more readable.

Since there is no conditional clause, we can easily follow the main business logic.

Conclusion

if-else and switch-case are easy to use and extend. As the software grows, the size of the clause gets easily big. If you think it is not readable, it’s time to refactor by applying one of the techniques in this article.

If you want to learn how to write clean code, the following books are my recommendations.

If you learn Design Patterns, check the following books.

Especially, you should read this refactoring book. This book will definitely enhance your programming skill. It is written in TypeScript.

Comments

Copied title and URL