Function Composition in JavaScript with Pipe
This post uses ES6 syntax. The same things can be accomplished with ES5, but it would require more verbose syntax.
I cover this in video format in my new egghead.io course
Full Course - Build Your First Production Quality React App
When writing JavaScript (or any other language for that matter), you often find yourself having to call a series of functions to get from some starting value to the desired result. To keep the examples simple, let's use a couple of really basic functions:
const inc = (num) => num + 1
const dbl = (num) => num * 2
We have two functions that each accept a number and return a new number. The first just increments the supplied value and the second takes whatever number is passed in and doubles it.
In our very contrived sample code, we want to get the result of incrementing some value, then doubling the result of that. A pretty standard approach to this would be to call the first function, assigning it's value to a variable (or, a constant), then passing that value into the second function, like so:
const startValue = 2
const plusOne = inc(startValue) // 3
const result = dbl(plusOne) // 6
This works, and there isn't necessarily anything wrong with this approach, but it does require that you create an extra variable (plusOne
). Admittedly, in this case, the extra variable isn't a huge deal, but if you were following this approach for code with many steps, that would mean creating many variables. Many variables means coming up with meaningful names for them and, as we all know, naming things is hard.
You could avoid the intermediate variables by nesting the function calls. Since plusOne
is just holding the return value from inc(startValue)
, you can easily replace that variable directly with the function call. So the updated code would be:
const startValue = 2
const result = dbl(inc(startValue)) // 6
We've removed the extra variable, and we didn't have to name anything extra, but this code isn't as clear as the previous code. You might argue that this isn't really any harder to read, but if you can imagine this same approach with four or five steps, the nested function calls would quickly create a single line of code that would take some focus to decipher. So while this might not be too bad for two function calls, this approach doesn't scale very well.
Introducing The Pipe Function
We can get the benefits of the nested functions in a more readable fashion by abstracting the nesting of the functions into a utility function that is commonly referred to as pipe
.
The goal of pipe
is to end up replacing our code from above with something more like this:
const incThenDbl = pipe(inc, dbl)
const result = incThenDbl(2)
Not only did we eliminate the intermediate variable, but we also did it in a single line without any confusing nesting. Instead, we created a line of code that reads more like english. It basically says, "Give me a function that pipes data through inc
, then dbl
". Also, if this series of steps can be applied to multiple situations, we now have a reusable function (incThenDbl
) that we can use wherever we need it.
So now all we need to do is define the pipe
function.
If you look at the sample of how we plan to use pipe
, you'll notice two key things that are happening:
- Both arguments to
pipe
are functions themselves - The value returned from
pipe
is also a function
The pipe
utility is possible in JavaScript because JavaScript functions are first class. That is, functions in JavaScript can be passed around like any other value.
So we know that pipe is going to accept two functions as arguments and return a function. That means we will end up with something like this:
const pipe = (f, g) => () => /*do stuff*/
We have a function that takes two arguments (these will be functions that we refer to as f
and g
) and returns a new function. We're not done yet, but we're getting close.
Referring back to the sample code, the returned function accepts an argument, so let's add that:
const pipe = (f, g) => (args) => /*do stuff*/
Now we have the parts we need, so let's use those functions. We want to call f
first and then pass the results of that to g
. We need to nest the call to f
in a call to g
, so the next step in building out pipe would look like this:
const pipe = (f, g) => (args) => g(f())
Since f
is going to be a reference to inc
in our example, it needs to receive the argument we pass into our returned function, so it should look like this:
const pipe = (f, g) => (args) => g(f(args))
This takes in functions f
and g
and returns the inner function. This inner function is a closure, so it has access to the functions passed into the outer function. When you call the inner function with a value, it is passed to f
, the return value of f
is passed into g
and the result of that call is returned from the inner function.
The only thing that's missing from this, is the ability to pass multiple arguments to f
. This is an easy fix with ES6 rest and spread operators (which happen to look identical).
We'll rewrite pipe
like so:
const pipe =
(f, g) =>
(...args) =>
g(f(...args))
The only change here is the addition of three dots (...
) in front of each reference to args
.
Those 3 Magical dots
The first time we use the three dots, it's the rest
operator. This takes the arguments and packages them up as an array.
With the rest
operator, we can accept a variable number of arguments and end up with a single array that contains all of our arguments.
const fn = (...args) => console.log(args)
fn(1, 2, 3) //[1, 2, 3]
fn('one', 'two') //['one', 'two']
You may have noticed that to log out the args
, we use the rest
operator to accept the args
value, but then we used args
without the three dots to log it out as an array.
So what's up with the second use of ...args
in the pipe
function?
Well, the second time we use ...args
, we're using the three dots as a spread
operator. The spread operator takes an array and spreads the values out into individual arguments. So we could use this if we had a function that expects 2 arguments and our values were locked up in an array:
const add = (a, b) => a + b
const result = add(...[1, 2]) // 3
If we tried to pass that array in without using the spread operator, it would be seen as the first argument (a
) and add
wouldn't receive a second argument. But with spread, a
will receive 1 and b
will receive 2.
Back to pipe:
const pipe =
(f, g) =>
(...args) =>
g(f(...args))
So in our pipe
function, we gather up whatever arguments are passed into an array called args
, then we pass them back into our function f
as individual arguments.
In this form, pipe will work for our original sample code:
const pipe =
(f, g) =>
(...args) =>
g(f(...args))
const incThenDbl = pipe(inc, dbl)
const result = incThenDbl(2) // 6
Note: I should point out that even though we've done the work to accept multiple arguments, that only applies to the first function in the pipeline. The second function is going to receive the result of the first function, and since a function can only return one value, that means only one value will be passed to the second function. (you can work around this with partial application, but that is a topic for another post.)
This will work:
const add = (a, b) => a + b
const dbl = (num) => num * 2
const pipe =
(f, g) =>
(...args) =>
g(f(...args))
const sumThenDbl = pipe(add, dbl)
const result = sumThenDbl(2, 1) // 6
But if we called add
as the second function in pipe
, it would only receive a single argument(the return value of the preceding function).
Piping More Than Two Functions
Our current pipe
function is great, but it only works with two functions. So what if we want to pipe together three, four or even ten functions?
Since pipe
takes in two functions and returns a new functions, we can use the result of pipe
as an argument to pipe
.
const square = (n) => n * n
const sumDblSquare = pipe(pipe(add, dbl), square)
const result = sumDblSquare(2, 1) // 36
Here, I added a square
function and then created a function by piping add
to dbl
, then piped the results of that to square
.
I could nest calls to pipe indefinitely, and eventually I could build up a long pipeline, but the code is going to get harder and harder to follow. It would be much nicer if we could just call pipe with all the functions we need to call.
Piping A Variable Number of Functions
To make our pipe function directly handle multiple functions we'll have to make some changes, but don't worry, none of the work we've done so far will go to waste.
Let's get ready for the next step by renaming the pipe
function we just created (we'll just prefix it with an underscore). And while we're at it, let's start defining a new pipe
function just below it.
const _pipe = (f, g) => (...args) => g(f(...args))
const pipe = /*The new pipe function goes here*/
So the goal for the updated pipe function is to be able to do something like this:
const inc = (num) => num + 1
const dbl = (num) => num * 2
const sqr = (num) => num * num
const incDblSqr = pipe(inc, dbl, sqr)
const result = incDblSqr(2) // 36
We're only piping together three functions here, but when we're done with pipe
, it'll work with any number of functions we pass into it.
We can see from the example above, calling pipe will still return a new function that will accept arguments and then execute the function pipeline. The big difference here is that instead of exactly two functions, we can now have a variable number of functions. The easiest way to handle a variable list of values is with an array. We have already seen that we can take a list of function arguments and convert it to an array using the rest
operator.
So the beginning of our new pipe
function will look like this:
/*omitting the _pipe function for clarity*/
const pipe = (...fns) => /*Do something*/
So we'll accept all of the arguments and inside the function, they will be accessible in an array called fns
.
Now our function has an array of functions and we expect it to return a single function. Anytime we have an array and we want to use its values to return a single value, we can accomplish that with reduce
. So our function might look more like this:
/*omitting the _pipe function for clarity*/
const pipe = (...fns) => fns.reduce((acc, val) => /*Do something & return the updated acc*/)
Here, reduce takes a function that takes in an accumulator and the current value. We're also calling it without an initial accumulator value, so in this case, reduce
will take two values from the array and pass them both in, one as acc
and the second as val
. So our reducer function needs to take in two functions and return a new, composed function.
As luck would have it, we already have a function that takes in two functions and returns a new function... it's our previous pipe
function that we have renamed _pipe
. So that means we can update our code to look like this:
const _pipe =
(f, g) =>
(...args) =>
g(f(...args))
const pipe = (...fns) => fns.reduce(_pipe)
If this is clear, you can jump down to the final version of the pipe
code. If not, I'll try to break this down a bit more.
A Step-By-Step Breakdown
For this call to pipe
:
pipe(fn1, fn2, fn3, fn4, fn5)
We'll end up with an array of functions:
;[fn1, fn2, fn3, fn4, fn5]
The reduce
method of that array will be called, using _pipe
as it's reducer function. On the first call to _pipe
, the first two values of the array will be passed as arguments:
_pipe(fn1, fn2)
That will result in a function that is equivalent to:
;(...args) => fn2(fn1(...args))
For now, let's call this resulting function pfn1
(pipe function 1... naming things is hard.)
So, the reducer function (_pipe
) will be called again, with the accumulator value (pfn1
) and the next item in the array fn3
, so this: _pipe(pfn1, fn3)
will be the equivalent of (...args) => fn3(pfn1(...args))
. We can call the result of this pfn2
and follow this through to the end.
The reducer function will then be called with pfn2
and fn4
, resulting in (...args) => fn4(pfn2(...args))
. We'll call this pfn3
The reducer function will then be called with pfn3
and fn5
, resulting in (...args) => fn5(pfn3(...args))
. Our resulting function is now a composition of all five functions.
This makes: pipe(fn1, fn2, fn3, fn4, fn5)
do the same thing as (...args) => fn5(fn4(fn3(fn2(fn1(...args)))))
. Clearly, the first option is much more readable.
The Complete pipe Code
const inc = (num) => num + 1
const dbl = (num) => num * 2
const sqr = (num) => num * num
// Pipe
const _pipe =
(f, g) =>
(...args) =>
g(f(...args))
const pipe = (...fns) => fns.reduce(_pipe)
const incDblSqr = pipe(inc, dbl, sqr)
const result = incDblSqr(2)
console.log(result)
Bonus - compose Functions Right to Left
If you'd prefer to compose your functions from right to left, you can create a compose
function by using reduceRight
instead of reduce
. This will start with the last function you pass in, and work it's way to the left.
const inc = (num) => num + 1
const dbl = (num) => num * 2
const sqr = (num) => num * num
// Pipe
const _pipe =
(f, g) =>
(...args) =>
g(f(...args))
//Compose
const compose = (...fns) => fns.reduceRight(_pipe)
const incDblSqr = compose(sqr, dbl, inc)
const result = incDblSqr(2)
console.log(result)