In the previous article of this series we looked at functional pipes and how they help us write cleaner, simpler and clearer code. In this article we will look at the brother of the pipe function, the compose function!
Note: You shouldn't confuse this with functional composition since both functional pipe and functional compose use functional composition but are not that in and of themselves.
The main difference between the pipe function and the compose function is the order of execution for each function provided. Namely, with a pipe we execute outside to inside (top to bottom) but with a compose function we execute inside to outside (bottom to top)!
Take the following example of an arbitrary calculation being run:
const add = (base, adder) => base + adder;
const multiply = (base, multiplier) => base * multiplier;
const divide = (base, divisor) => base / divisor;
const subtract = (base, minuser) => base - minuser;
const number = 5;
/**
* Equivalent to: (((((5 * 5) + (5 * 5)) + 1) - 8) / 2) + 5
*/
const output = add(
divide(
subtract(
add(
add(
multiply(number, number),
multiply(number, number)
), 1
), 8
), 2
), 5
);
console.log(output); // 26.5
As we can see in this example, the code for calculating based on even the basics can get really complicated pretty quickly. Compose functions aim to help reduce that complexity by providing a way to declare the actions to be taken from inside to outside and drive a better understanding of how your code actually works from 1 level instead of in the case of our example, 6 levels deep.
Tests
describe("Compose", () => {
it("Should throw for invalid parameters", () => {
expect(() => compose("string")).toThrowError(TypeError);
});
it("Should allow functions to be passed by reference", () => {
const addOne = number => number + 1;
const double = number => number * 2;
const result = compose(
addOne,
double
)(5);
expect(result).toBe(11);
});
it("Should allow anonymous functions to be passed", () => {
const result = compose(
number => number + 1,
number => number * 2
)(5);
expect(result).toBe(11);
});
it("Should return correctly when values are generated from sub composers", () => {
const addOne = number => number + 1;
const double = number => number * 2;
const result = compose(
addOne,
double,
number => compose(
addOne
)(number)
)(5);
expect(result).toBe(13);
});
});
describe("ComposeWith", () => {
it("Should return as expected", () => {
const addOne = number => number + 1;
const double = number => number * 2;
expect(composeWith(5, addOne, double)).toBe(11);
});
});
These tests are essentially the same as the ones we implemented in the functional pipes article but the outputs are different because a compose
function and a pipe
function run the order of operations in different directions. This impacts how values are transformed and changes are applied as either function is run.
Let's consider the composeWith
test for the compose:
5
-> double -> 10
-> addOne -> 11
The pipeWith
implementation however would execute:
5
-> addOne -> 6
-> double -> 12
The thing is that even though the pipe
and compose
functions have almost identical implementations they serve different purposes.
- A
pipe
is useful for procedural actions which must be executed in order to generate an output from outside to inside (left to right/top to bottom) - A
compose
function is useful when you need to build up an output from inside to outside (right to left/bottom to top)
It's a subtle difference but as you see in the examples above it does matter because output will differ between whichever one you use and thus they are not interchangeable with one another in every scenario even though it can sometimes be possible to do so.
I hope that makes any sense at all because even as I write this I can see how for some the difference may still be a little vague and can take some getting used to but it is worth trying to understand as both are powerful abstractions and allow us to take more control over the flow of our code. 😅
Implementation
/**
* @function compose
* @description A function composer to apply over a given value
* @param {Function[]} fns - The functions to call when a value is provided
* @returns {Function} The function where the value to call the composer on is provided
*/
function compose(...fns) {
const parameters = fns.reduce((output, value) => output.concat(value), []);
if(parameters.every(fn => typeof fn === "function") === false) {
throw new TypeError("Parameter 1 must be either of type Function[] or if multiple parameters are provided then each one should be of type Function but this requirement has not been met.");
}
return input => parameters.reduceRight((prev, fn) => fn(prev), input);
}
/**
* @function composeWith
* @description A function to apply a composer function to a given value
* @param {*} value - The value to apply the composer to
* @param {Function[]} fns - The functions to call when a value is provided
* @returns {*} The result of the composer
*/
function composeWith(value, ...fns) {
return compose(...fns)(value);
}
This implementation should remind you of the functional pipe implementation from the last article in this series because it is basically the same.
The big difference is the use of reduceRight
which takes the input functions and runs the reducer over them from right to left. On each iteration the result of the previous function call is passed into the next. This is how we get the inside to outside application of the functions running as we discussed earlier. It is also how the functions are called from bottom to top in the visible order of operations.
Taking our example from the beginning of this article we can see how much simpler the code becomes in the example below:
const add = (base, adder) => base + adder;
const multiply = (base, multiplier) => base * multiplier;
const divide = (base, divisor) => base / divisor;
const subtract = (base, minuser) => base - minuser;
const number = 5;
const calculator = compose(
dividend => add(dividend, 5),
difference => divide(difference, 2),
sum => subtract(sum, 8),
sum => add(sum, 1),
product => add(product, product),
number => multiply(number, number)
);
console.log(calculator(number)); // 26.5
We could also write the compose
slightly differently if we use the composeWith
helper like so:
const add = (base, adder) => base + adder;
const multiply = (base, multiplier) => base * multiplier;
const divide = (base, divisor) => base / divisor;
const subtract = (base, minuser) => base - minuser;
const number = 5;
const result = composeWith(
number,
dividend => add(dividend, 5),
difference => divide(difference, 2),
sum => subtract(sum, 8),
sum => add(sum, 1),
product => add(product, product),
number => multiply(number, number)
);
console.log(result); // 26.5
The code works exactly as before but reading from bottom to top we can see how this inside to outside idea that we discussed actually works.
In the original example this created an awkward tree of hard to track items. Imagine that we had a more in depth calculation though, if that were the case then we would have a hell of a mess. With the compose
function however we can provide clarity to our code and get the same result with a lot less work required to tracking and apply changes to values.
Conclusions
This is one of the more complex helpers to understand but once you do, you will be using it in quite a few situations I'm sure. The compose
function is a very powerful tool to have in your arsenal so go and see how it can help provide clarity and cleanliness to your codebase. Let me know if you have any implementation ideas or similar helper functions that you use day-to-day in the comments below!
Top comments (4)
Nice article series!
Just pointing out: an alternative that could give even cleaner code for this specific calculation example is to use higher-order functions.
So instead of:
You could have this:
This way you're creating the functions on the fly in
composeWith
. I'd argue that comes closer to natural language ("first multiply by 2, then add 1").This is basically a teaser for your next article about currying :)
Yeah, I had considered that when writing the article but I was wondering if beginner devs might get confused somewhat with executing a function in the queue before the queue is run.
Seeing your example though, I may have to reconsider that since it isn’t as it was in my head at first glance and you have a point that it feeds into the next article on currying anyway.
By the way, I noticed you work at Columbia road, I’m at Futurice so we are basically working at sister companies 😅... small world!
Yeah, I see that higher-order functions like that could be confusing if used without much explanation.
But your currying examples make these higher-order functions even nicer to create (eg.
curriedAdd
). It could be cool to refer back to this example at the end of the currying article, as a kind of "application of what we have learned: now we can make the composition look cleaner!)This is no coincidence ;) I came here through the last Futu Dev Breakfast newsletter that linked to your article about piping!
I didn't even know Lucia added my article there... Fair enough though, good to know!