Clean Code Applied to JavaScript - Part III. Functions


This post is the third of an interesting series of posts that will delve into the well-known topic that is "Clean Code" but applied to JavaScript.

In this series, we are going to discuss the classic tips around clean code that every programmer should know but applied to a specific JavaScript/TypeScript language.


Introduction

In this post, we are going to present the fundamental tips and advice to generate clean code focusing on the element that allows us to reuse our code: The functions.

All our examples are illustrated with JavaScript but these good practices should be applied in any programming language, including the programming languages "closest to the metal". I do this comment because I have had discussions with colleagues who are working in languages like C or Go and do not like applying these practices arguing that in their programming language "nobody" does. Then, I always answer that someone has to be the first to break the deck as long as it is to try to improve. However, this comment gives for long and pleasant conversations between colleagues in which advantages and disadvantages of these practices are discussed.

Therefore, we start with the tips to generate good code applied specifically to the variables.

Use default arguments instead of short circuiting or conditionals

In most programming languages, you can define default arguments in functions. This fact will allow us to eliminate having to use conditional or short circuits in the body of the code.

This example is illustrated in the following codes.

function setName(name) {
    const newName = name || 'Juan Palomo';
}
function setName(name  = 'Juan Palomo') {
    // ...
}

Function arguments (2 or fewer ideally)

This concept is essential to improve the quality of your code.
You should reduce the number of function arguments. An adequate number could be 2 or less, but don't get obsessed with the numbers since these depend on the specific programming language we are using.

This recommendation is very important because, although we don't believe it, when we have many arguments, usually several are grouped together composing an object. We have to escape using primitives (types like string, number, boolean, etc.) and start using objects that are at a higher level of abstraction. In fact, we would be closer to the business logic and increasingly further from the low level.

In the first example shown below, we would have a creative function of a hamburger that receives 4 parameters. These parameters are fixed and in that order, this limits us a lot. In fact, it returns to the very rigid function.

A considerable improvement is to use an object such as a burger to create a new hamburger. In this way, we have grouped the attributes to a single object (In this case, it would be a flat object, without a prototype).

In the third example, we could use destructuring to the object sent and we could have the attributes accessible to the body of the function but we are really using a single parameter, which allows us greater flexibility.

function newBurger(name, price, ingredients, vegan) {
    // ...
}

function newBurger(burger) {
    // ...
} 

function newBurger({ name, price, ingredients, vegan }) {
    // ...
} 
const burger = {
    name: 'Chicken',
    price: 1.25,
    ingredients: ['chicken'],
    vegan: false,
};
newBurger(burger);

Avoid side effects - Global Variables

Side effects are a source of troubles in the future. Not necessarily having a side effect is detrimental by definition but the chances of having errors in our code grows vertiginously as we include more side effects.

Therefore, the recommendation in this section is, avoid side effects at all costs to be able to generate functions that can be tested, apply techniques such as memoization and other advantages that we cannot describe in this post.

The following example is the classic side effect in which a function modifies a variable or object that is outside its scope. This function cannot be tested because it has no arguments to test, in fact, the state of the variable that it modifies is not controlled or adequately managed by the function itself that modifies it.

The easiest way to avoid this side effect is passing the variables that are within the scope of this function (Something obvious but not so obvious when we have to have it aimed to remember it over time) as an argument.

let fruits = 'Banana Apple';

function splitFruits() {
    fruits = fruits.split(' ');
}

splitFruits();

console.log(fruits); // ['Banana', 'Apple'];
function splitFruits(fruits) {
    return fruits.split(' ');
}
    
const fruits = 'Banana Apple';
const newFruits = splitFruits(fruits);
    
console.log(fruits); // 'Banana Apple';
console.log(newFruits); // ['Banana', 'Apple'];

Avoid side effects - Objects Mutables

Another side effects that cost more to understand junior programmers or even veteran programmers who have worked modifying data using the same object through different parts of the code.

One of the main side effects that causes us to lose many of the advantages of software development in which this feature is avoided is the modification of objects. If you are in the Web world, you will know that JavaScript has been a great "mutator" of objects since its birth and that there are many libraries that aim to avoid mutating objects (creating new objects).

If the previous example of modifying a variable from a function when the variable is outside the scope has seemed logical to you, the mutation of an object by a function should also have the same effect.

In JavaScript, the methods that work with the Array data structure are divided between those that make mutations to the objects and those that do not. For example, the operations, push, pop or sort work on the same data structure while the filter, reduce or map operations generate new data structures and do not mutate the main one.

If you want to create clean and maintainable code by a development team you have to get used to looking for protocols and work patterns that improve the understanding of the code and business logic by all team members, even though we have software slightly less efficient or even more verbose.

I give you two clear examples in which one is making a mutation on the cart data structure and the other in which no such mutation is made.

const addItemToCart = (cart, item) => {
    cart.push({ item, date: Date.now() });
}; 
const addItemToCart = (cart, item) => {
    return [...cart, {
                item, 
                date: Date.now(),
            }];
};

Functions should do one thing

This is one of the programming principles that are heard in all programming schools on a daily basis, but in practice they are not fulfilled due to the lack of putting theory into practice.

Each function must do only one conceptual task. It makes no sense to mix concepts or tasks. Naturally, a set of small tasks together will make a larger task but the tasks should not be intermingled, this is known as coupling.

Therefore, a function should only do one thing. In the following example, we have modeled the function that sends emails to customers by receiving a list of active clients. Conceptually, it is a simple business rule but when implementing it they are two clearly differentiated tasks.

function emailCustomers(customers) {
    customers.forEach((customer) => {
        const customerRecord = database.find(customer);
        if (customerRecord.isActive()) {
            email(client);
        }
    });
}

First of all, we must filter the users that are active, and that is an independent function to the previous one. You should fear when you write an "if" in your code. It does not mean that an if is synonymous with something badly done, the abuse of the if, surely they are.

Once we have filtered the clients that are active, we need another function that is in charge of sending the email to each of the clients.

function emailActiveCustomers(customers) {
    customers
        .filter(isActiveCustomer)
        .forEach(email);
    }
    
function isActiveCustomer(customer) {
    const customerRecord = database.find(customer);
    return customerRecord.isActive();
}

Remember, you should focus on that each function will only do one thing.

Functions should only be one level of abstraction

Another requirement that we have to fulfill when we are designing functions is that each function should only have a single level of abstraction.

The following example shows a possible function that parses in JavaScript. In this function you can see how there are different levels of abstraction.

function parseBetterJSAlternative(code) {
    const REGEXES = [
        // ...
    ];
    
    const statements = code.split(' ');
    const tokens = [];
    REGEXES.forEach((REGEX) => {
        statements.forEach((statement) => {
        // ...
        });
    });
    
    const ast = [];
    tokens.forEach((token) => {
        // lex...
    });
    
    ast.forEach((node) => {
        // parse...
    });
}                  

The technique to solve this problem is quite simple, we just have to identify the different levels of abstraction and create functions that meet the requirements explained throughout this article. Therefore, our function after applying refactoring would be as follows:

const REGEXES = [ // ...];
function tokenize(code) {    
    const statements = code.split(' ');
    const tokens = [];
    REGEXES.forEach((REGEX) => {
        statements.forEach((statement) => {
            tokens.push( /* ... */ );
        });
    });
    return tokens;
}
function lexer(tokens) {
    const ast = [];
    tokens.forEach((token) => ast.push( /* */ ));
    return ast;
}
function parseBetterJSAlternative(code) {
    const tokens = tokenize(code);
    const ast = lexer(tokens);
    ast.forEach((node) => // parse...);
}

Favor functional programming over imperative programming

Without wanting to enter into debate between programming paradigms since it is not the objective of this post you should try to learn the functional paradigm and use it on the imperative paradigm.

I recommend reading Alvin Alexander's Blog and specifically the post in which he describes the benefits of functional programming.

Below, I summarize the main advantages of using functional programming on the imperative.

  1. Pure functions are easier to reason about
  2. Testing is easier, and pure functions lend themselves well to techniques like property-based testing
  3. Debugging is easier
  4. Programs are more bulletproof
  5. Programs are written at a higher level, and are therefore easier to comprehend
  6. Function signatures are more meaningful
  7. Parallel/concurrent programming is easier

Another feature of functional programming versus imperative programming is that the code is more readable. If you read the first post of this series of posts you will see that one of the characteristics that makes a quality code compared to other codes is that it is readable for humans.

Therefore, we have endless advantages associated with functional programming; nevertheless, for junior programmers who learned with a paradigm and began to solve problems, it is hard for them to work with this programming paradigm since it changes their work habits. If that is your case, perhaps you are in the wrong profession.

In this industry, we have to adapt to change and above all have a huge case of tools that allow us to use it in every situation.

Observe the code in which a simple counter is made, you have to keep several variables in mind: total, i, items, items.length, price; while in the functional implementation we would only have: total, price and items. In the case that you are accustomed to functional operators, its reading is quite fast and friendly.

const items = [{
    name: 'Coffe',
    price: 500
  }, {
    name: 'Ham',
    price: 1500
  }, {
    name: 'Bread',
    price: 150
  }, {
    name: 'Donuts',
    price: 1000
  }
];
let total = 0;
for (let i = 0; i < items.length; i++) {
  total += items[i].price;
}
const total = items
  .map(({ price }) => price)
  .reduce((total, price) => total + price);

Use method chaining

When we design functions that operate on objects or data flows (in this example an object) they are usually functions that do a single task, with a single level of abstraction and without side effects which causes that to perform complex tasks we need to perform the combination of several of them. Therefore, it develops chained methods since they allow a more readable code, and it is the side effect of having been performing the previous "duties" well when designing the functions.

If you know Linux, you have to think that all the commands are intended to do just one thing and do it well but we have a complex operating system working with simple functions. This is achieved thanks to the use of pipes to combine the different commands.

In our specific case, we have to build something similar, whether using objects or functions. In the following examples, we illustrate the Car class in which chained methods are used versus traditional ones.

class Car {
    constructor({ make, model, color } = car) {
        /* */
    }
    setMake(make) {
        this.make = make;
    }
    setModel(model) {
        this.model = model;
    }
    setColor(color) {
        this.color = color;
    }
    save() {
        console.log(this.make, this.model, this.color);
    }
}    
const car = new Car('WV','Jetta','gray');
car.setColor('red');
car.save();
class Car {
    constructor({ make, model, color } = car){}
    setMake(make) {
        this.make = make;
        return this;
    }
    setModel(model) {
        this.model = model;
        return this;
    }
    setColor(color) {
        this.color = color;
        return this;
    }
    save() {
        console.log(this.make, this.model, this.color);
        return this;
    }
}
const car = new Car('WV','Jetta','gray')
.setColor('red')
.save();

Conclusions

Throughout this post, we have addressed how to apply clean code to a fundamental piece for developers, which we find in all programming languages: Functions.

The design of functions applying clean code is essential because the functions are the basic element to decouple the code. However, bad practices in the design of functions can lead us to keep the code as coupled as without them but with the complexity of introducing functions. In addition, the poor design of the functions leads to serious bugs that are difficult to find. As we rise in the levels of software abstraction, it will be more difficult to locate the points where the bugs occur.

Therefore, the recommendations presented in this post will make you scale a level in the quality of your code, but do not apply them without sufficient reflection. Remember, there are no magic tips or silver bullets but there is a set of techniques that will allow you to solve a wider range of problems.

Finally, the points we have addressed are the following:

  • Use default arguments instead of short circuiting or conditionals.
  • Function arguments (2 or fewer ideally).
  • Avoid side effects - Global Variables.
  • Avoid side effects - Objects Mutables.
  • Functions should do one thing.
  • Functions should only be one level of abstraction.
  • Favor functional programming over imperative programming.