Andrew Vo-Nguyen
October 4, 2023
5 min read
The motivation to implement a calculator using function chaining is to demonstrate how this pattern can improve code readability and can logically process actions from left to right. Here is a simple example of how some calculations are implemented with the use of pure functions:
1function add(valueA: number, valueB: number) {
2 return valueA + valueB;
3}
4
5function subtract(valueA: number, valueB: number) {
6 return valueA - valueB;
7}
8
9function multiply(valueA: number, valueB: number) {
10 return valueA * valueB;
11}
12
13function divide(valueA: number, valueB: number) {
14 return valueA / valueB;
15}
16
17const calculation = divide(multiply(subtract(add(3, 2), 1), 5), 2);
18
19console.log(calculation); // 10
The problem with this calculation is that it is difficult to determine the order of operations at first glance. The deeply nested add
function occurs first, and its result is propagated up to subtract
, then multiply
and finally to divide
.
To tackle this problem we can implement a pattern called function chaining (borrowed from object-oriented programming). Don't worry, if you are not a fan of classes, we can hide the implementation. Ideally, we want to land with something like this:
1calculator(3).add(2).subtract(1).multiply(5).divide(2).calculate();
This syntax is much easier to process logically from left to right. It also provides an easy-to-use auto-completion similar to JavaScript's array methods or string methods.
Note that this implementation does not perform any calculations until calculate()
is run. This is to allow us to create re-usable templates or schemas that can be run only once we run calculate()
. For example:
1const taxSchema = (amount: number, taxRate: number) => calculator(amount).multiply(taxRate);
2
3// At at later date we can run the calculation
4const tax = taxSchema(100, 0.15).calculate();
5console.log(tax); // 15
In order to only run the calculations on calculate()
we have to ensure that no processing is run when we add()
, subtract()
, multiply()
or divide()
. In order to do that, we have to keep a queue of operations and process that queue on calculate()
.
Let's start with the baseline implementation using a JavaScript class. Classes are just syntactic sugar over object prototypes but are preferred in TypeScript.
1function add(valueA: number, valueB: number) {
2 return valueA + valueB;
3}
4
5function subtract(valueA: number, valueB: number) {
6 return valueA - valueB;
7}
8
9function multiply(valueA: number, valueB: number) {
10 return valueA * valueB;
11}
12
13function divide(valueA: number, valueB: number) {
14 return valueA / valueB;
15}
16
17type OperationFn = (value: number) => number;
18
19class Calculator {
20 private initial: number;
21 private queue: OperationFn[];
22
23 constructor(initial = 0) {
24 this.initial = initial;
25 this.queue = [];
26 }
27
28 add(valueB: number) {
29 this.queue.push((valueA) => add(valueA, valueB));
30 return this;
31 }
32
33 subtract(valueB: number) {
34 this.queue.push((valueA) => subtract(valueA, valueB));
35 return this;
36 }
37
38 multiply(valueB: number) {
39 this.queue.push((valueA) => multiply(valueA, valueB));
40 return this;
41 }
42
43 divide(valueB: number) {
44 this.queue.push((valueA) => divide(valueA, valueB));
45 return this;
46 }
47
48 calculate() {
49 let result = this.initial;
50 for (const operation of this.queue) {
51 result = operation(result);
52 }
53 return result;
54 }
55}
Let's break down everything going on in this code block:
When we instantiate the Calculator
class with the new
keyword, we need to provide a starting number. If no initial value is set, it is defaulted as 0
. The queue is a list of OperationFn
function references that we will execute using the calculate()
method.
add()
, subtract()
, multiply()
and divide()
all accept a number that is used to perform on the running value. The running value is the persistent number (set initially in the constructor). These so-called "operation gatherers" do not perform any mutation on the running value, rather they just add the OperationFn
to the queue for later processing.
add()
, subtract()
, multiply()
and divide()
all return a reference to the class instance by returning this
. This is the magic sauce that allows us to continually call these methods in a chain. Every method call in the chain just adds an operation function reference to this.queue
.
This method finally runs all the operations in the chain iteratively. As you can see, it starts with the initial value and keeps a running result after each operation function call. Note that this.initial
is not mutated and preserved if required in the future.
To use our new calculation we can instantiate our class and chain methods like so:
1const result = new Calculator(3).add(2).subtract(1).multiply(5).divide(2).calculate();
2
3console.log(result); // 10
After every method call, the instance of Calculator
is returned giving us auto-complete like this:
Not a fan of object-oriented JavaScript? The instantiation of the class can be abstracted if you wish like so:
1const calculator = (initial = 0) => new Calculator(initial);
2
3const result = calculator(3).add(2).subtract(1).multiply(5).divide(2).calculate();
4
5console.log(result); // 10
And there we have it. A developer-friendly calculator that processes methods from left to right. Of course, the calculator is a simplified example. The function chaining pattern can be applied to a lot more complex data structures and use cases. My favourite use cases of function chaining in the wild are validation libraries such as zod
, yup
and joi
.