When functional programming is discussed, it often talks about the mechanism, and not about the basic principles. Functional programming is not about monads or monoids, it is primarily about writing programs using generalized functions. This article is about applying functional thinking in TypeScript code refactoring.
Note from the translator: for convenience, I designed all the code in the repository .
For this we will use three techniques:
Let's start now!
So, we have two classes:
Employee
export default class Employee { constructor(public name: string, public salary: number) {} }
Department
export default class Department { constructor(public employees: Employee[]) {} works(employee: Employee): boolean { return this.employees.indexOf(employee) > -1; } }
Employees have names and wages, and a department is just an ordinary list of employees.
The averageSalary function is exactly what we will refactor:
export default function averageSalary(employees: Employee[], minSalary: number, department?: Department): number { let total = 0; let count = 0; employees.forEach((e) => { if(minSalary <= e.salary && (department === undefined || department.works(e))){ total += e.salary; count += 1; } }); return total === 0 ? 0 : total / count; }
The function takes the list of employees, the minimum wage and optionally the department. If it is set, it will calculate the average wage in this department, if not, the average for all departments.
describe("average salary", () => { const empls = [ new Employee("Jim", 100), new Employee("John", 200), new Employee("Liz", 120), new Employee("Penny", 30) ]; const sales = new Department([empls[0], empls[1]]); it("calculates the average salary", () => { expect(averageSalary(empls, 50, sales)).to.equal(150); expect(averageSalary(empls, 50)).to.equal(140); }); });
Despite the rather clear conditions, the code was a bit confusing and difficult to expand. If I add another condition, the function signature (and thus its public interface) can change, and if else constructs can turn the code into a real monster.
Let's use some of the functional programming techniques to refactor this function.
Using functions instead of primitives may initially seem like an illogical step, but in reality it is a very powerful technique for generalizing code. In our case, this means replacing the minSalary and department parameters with two functions with condition checking.
Step 1 (Predicate is an expression that returns true or false)
type Predicate = (e: Employee) => boolean; export default function averageSalary(employees: Employee[], salaryCondition: Predicate, departmentCondition?: Predicate): number { let total = 0; let count = 0; employees.forEach((e) => { if(salaryCondition(e) && (departmentCondition === undefined || departmentCondition(e))){ total += e.salary; count += 1; } }); return total === 0 ? 0 : total / count; } // ... expect(averageSalary(empls, (e) => e.salary > 50, (e) => sales.works(e))).toEqual(150);
We have unified the interfaces of the salary and department sampling conditions. This unification will allow to transfer all conditions in the form of an array.
Step 2
function averageSalary(employees: Employee[], conditions: Predicate[]): number { let total = 0; let count = 0; employees.forEach((e) => { if(conditions.every(c => c(e))){ total += e.salary; count += 1; } }); return (count === 0) ? 0 : total / count; } //... expect(averageSalary(empls, [(e) => e.salary > 50, (e) => sales.works(e)])).toEqual(150);
Now the array of conditions is a composition of conditions that we can make more readable.
Step 3
function and(predicates: Predicate[]): Predicate{ return (e) => predicates.every(p => p(e)); } function averageSalary(employees: Employee[], conditions: Predicate[]): number { let total = 0; let count = 0; employees.forEach((e) => { if(and(conditions)(e)){ total += e.salary; count += 1; } }); return (count == 0) ? 0 : total / count; }
It is worth noting that the "and" function is common, and should be moved to a separate library with a view to its further reuse.
Function averageSalary has become more reliable. New conditions can be added without changing the interface of the function and without changing its implementation.
Another useful practice in functional programming is to simulate all data changes as a stream. In our case, this means extracting the filter from the loop.
Step 4
function averageSalary(employees: Employee[], conditions: Predicate[]): number { const filtered = employees.filter(and(conditions)); let total = 0 let count = 0 filtered.forEach((e) => { total += e.salary; count += 1; }); return (count == 0) ? 0 : total / count; }
This change makes the counter useless.
Step 5
function averageSalary(employees: Employee[], conditions: Predicate[]): number{ const filtered = employees.filter(and(conditions)); let total = 0 filtered.forEach((e) => { total += e.salary; }); return (filtered.length == 0) ? 0 : total / filtered.length; }
Further, if we separate salaries separately, then we can use normal reduce to sum up.
Step 6
function averageSalary(employees: Employee[], conditions: Predicate[]): number { const filtered = employees.filter(and(conditions)); const salaries = filtered.map(e => e.salary); const total = salaries.reduce((a,b) => a + b, 0); return (salaries.length == 0) ? 0 : total / salaries.length; }
Next, we note that the last two lines of code do not contain any information about employees or departments. In fact, this is just a function to calculate the average value. So it can be generalized.
Step 7
function average(nums: number[]): number { const total = nums.reduce((a,b) => a + b, 0); return (nums.length == 0) ? 0 : total / nums.length; } function averageSalary(employees: Employee[], conditions: Predicate[]): number { const filtered = employees.filter(and(conditions)); const salaries = filtered.map(e => e.salary); return average(salaries); }
Thus, the extracted function is now generic.
After we have divided the logic of computing and filtering wages, let's proceed to the final step.
Step 8
function employeeSalaries(employees: Employee[], conditions: Predicate[]): number[] { const filtered = employees.filter(and(conditions)); return filtered.map(e => e.salary); } function averageSalary(employees: Employee[], conditions: Predicate[]): number { return average(employeeSalaries(employees, conditions)); }
Comparing the final decision, I can say that it is better than the previous one. First, the code is more generalized (we can add a new condition without breaking the interface of the function). Secondly, we got an immutable state and the code became more readable and more understandable.
The functional programming style is writing small functions that take collections of values ​​and return new collections. These functions can be reused or merged in different places. The only drawback of this style is that although the code may become more abstract, but at the same time it may become more difficult to understand the role of all these functions.
I like to use Lego-analogy: Lego cubes can be combined in different ways - they are easily combined with each other. But not all cubes are the same size. Therefore, when you refactor using the techniques described in this article, do not try to create functions that take Array<T>
for example, but return Array<U>
. Of course, in some rare cases, the data can be mixed, but such an approach will make it much more difficult to understand the logical chain of code.
In this article, I showed how to apply functional thinking when refactoring TypeScript code. I did this by applying simple transformation functions, following the rules:
“JavaScript Allonge” by Reginald Braithwaite
“Functional JavaScript” by Michael Fogus
Source: https://habr.com/ru/post/315204/
All Articles