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
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.
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
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
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
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
pipeare functions themselves
- The value returned from
pipeis also a function
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
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())
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
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).
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
The first time we use the three dots, it's the
rest operator. This takes the arguments and packages them up as an array.
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
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 (
add wouldn't receive a second argument. But with spread,
a will receive 1 and
b will receive 2.
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).
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?
pipe takes in two functions and returns a new functions, we can use the result of
pipe as an argument to
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
dbl, then piped the results of that to
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.
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
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
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.
For this call to
pipe(fn1, fn2, fn3, fn4, fn5)
We'll end up with an array of functions:
;[fn1, fn2, fn3, fn4, fn5]
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:
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
fn4, resulting in
(...args) => fn4(pfn2(...args)). We'll call this
The reducer function will then be called with
fn5, resulting in
(...args) => fn5(pfn3(...args)). Our resulting function is now a composition of all five functions.
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.
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)
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)