A. Rothuis

At the time of writing, there is an ongoing discussion in ECMAScript Technical Committee 39 (TC39) regarding the implementation of a pipeline operator. At the same time, there has been an open RFC in the PHP community regarding a pipe operator since 2016. It seems that multiple communities would like a pipeline operator. But why?

In this post, we will explore which problems a pipeline operator would solve and how other languages deal with these problems.

A case of confusion

Imagine we have an function called “confuse”, in which a word is reversed, capitalized and then exclaimed:

// Prints: "Looc!"
console.log(confuse("cool"));

We can implement each transformation in JavaScript separately:

const reverse = word => word.split("").reverse().join("");
const capitalize = word => word[0].toUpperCase() + word.substring(1);
const exclaim = word => word + "!";

Interesting to note is that these definitions are all unary functions: they take only single argument.

Then, we can write our “confuse” function:

const confuse = word => {
    const reversed = reverse(word);
    const capitalized = capitalize(reversed);
    const exclaimed = exclaim(capitalized);

    return exclaimed;
};

We first define every transformation function separately and then we use the output of one function as the result of the other. This chaining can be simplified because of the unary nature of the functions. Of course, we do not have to introduce temporary variables for the result, we could compose our function using the transformations.

const confuse = word => exclaim(capitalize(reverse(word)));

This is actually surprisingly readable. In the context of our program, to confuse means to exclaim something, capitalize it and reverse it. Right?

Not quite. It means: first reverse something, then capitalize it and finally exclaim it. The order of evaluation is different from the reading order! The innermost (or rightmost) function is executed first. This does not fit everyone’s mental model. Some people like to read code from left-to-right, especially if their native tongue is a left-to-right language. For those who are used to right-to-left language, or are more familiar with programming or mathematics, this could be less of an issue.

Another thing that could be improved is that we have to explicitly pass a parameter to the innermost function. If I say: to confuse
means reverse, capitalize and exclaim the type or meaning of the argument should not have to be specified: the confuse function is a composition of these transformation functions. This style of programming is called point-free programming, points being the explicit reference to the arguments of the function.

A pipeline operator could actually solve both issues:

  1. the arguments for the functions do not have to be named (point-free)
  2. the composition order is the same as the application order

Approaches in other languages

Before we dive in and look at how we can write our function in modern JavaScript using pipes or a pipeline, we will take a look at some other languages. Two of them are functional (Haskell, Elixir) and one of them is mixed (D). How can we do (ordered) composition of unary functions in these languages?

Haskell’s composition

There is no pipe or pipeline operator in Haskell. However, because Haskell supports operator overloading, you could create your own. In fact, the Flow package offers a couple of operators with the purpose of writing “more understable Haskell”.

The community has not unanimously accepted this as the language and the culture prefer composition and application. The pipe operator is seen as non-idiomatic. This is somewhat understandable given the more mathy background of most Haskell programmers, but – as a software engineer – I would say one should go with the most maintainable, easiest-to-understand solution. If a package like Flow helps with that, go for it. Alternatively, one could take a look at the more idiomatic way of programming using arrows, more specifically the >>> operator.

In Haskell we would write our transformation functions (including an optional explicit type declaration) like this:

import Data.Char (toUpper)

capitalize :: String -> String
capitalize (head:tail) = toUpper head : tail

exclaim :: String -> String
exclaim word = word ++ "!"

Note that we did not define the reverse function, because Haskell has this built in. The capitalize function deconstructs a string (a list of characters) into its first element (the head) and its other elements (the tail) and we define capitalize as applying : (the cons operator ; prepending an element to a list) to the uppercased head and the tail of its argument. The exclaim function works by concatenating (++) an exclamation mark to a word.

We could write this function in a more point-free manner, by rewriting the infix ++ operator as a prefix:

exclaim :: String -> String
exclaim = (++) "!"

This does not work, as it partially applies “!” as its first argument. So it adds the second argument to “!”. Or in other words, it prepends “!” to whatever the second argument will be. If only there was a way to flip the arguments. Luckily, there is:

exclaim :: String -> String
exclaim = flip (++) "!"

Now that we have defined our functions, we can combine them into our confuse function:

import Data.Char (toUpper)

capitalize :: String -> String
capitalize (head:tail) = toUpper head : tail

exclaim :: String -> String
exclaim = flip (++) "!"

confuse :: String -> String
confuse word = exclaim (capitalize (reverse word))

main = do
    -- prints "!Looc"
    putStrLn $ confuse "cool"

The definition of confuse is comparable to how we did it in JavaScript: nesting. It would be cool if we could lose all those parentheses and our argument. Haskell has the composition operator (.) for that.

confuse :: String -> String
confuse = exclaim . capitalize . reverse

Confusingly, being point-free in Haskell often means adding more points. “Point-free” means that the points (or: arguments) of the space on which the function acts are not explicitly mentioned. Keep in mind that composition is evaluated from right to left.

If we wanted to write our own pipe operator we could do so by flipping the arguments of our composition operator:

(|>) :: (a -> b) -> (b -> c) -> a -> c
(|>) = flip (.)

confuse :: String -> String
confuse = reverse |> exclaim |> capitalize

Recall that using a pipeline operator is, to some, seen as a less idiomatic way of writing Haskell. I would advise to stick with whatever the project’s and team’s conventions are.

Elixir’s pipe operator

In Elixir, there is a pipe operator. The same operator can be found in other languages like F#, Elm and Julia.

Using the pipe operator, we can write our transform functions as follows:

capitalize = fn word -> 
  word 
  |> String.slice(0,1)
  |> String.upcase()
  |> (fn head -> head <> String.slice(word, 1..-1) end).()
end

exclaim = fn word -> word <> "!" end

Elixir already has a String.reverse function, so we do not have to define one. Our capitalize function takes a single argument and pipes it to a function that takes the head. The head is piped to a function that uppercases it. The result is passed into an inline anonymous function that prepends the tail of the initial argument. The exclaim function concatenates our word with “!”.

We can use a short-hand syntax (&) to write our anonymous functions in a less explicit style, although we are referencing arguments by their position:

capitalize = fn word -> 
  word 
  |> String.slice(0,1)
  |> String.upcase()
  |> (&(&1 <> String.slice(word, 1..-1))).()
end

exclaim = &(&1 <> "!")

We can then write our code as:

capitalize = fn word -> 
  word 
  |> String.slice(0,1)
  |> String.upcase()
  |> (&(&1 <> String.slice(word, 1..-1))).()
end

exclaim = &(&1 <> "!")

confuse = fn word -> 
  word
  |> String.reverse()
  |> capitalize.()
  |> exclaim.()
end

# Prints "Looc!"
IO.puts(confuse.("cool"))

To be fair, although I like Elixir’s feature set, I am not that experienced in it. There probably are more elegant ways of defining our transformation functions.

D’s uniform function call synctax (UFCS)

In D, there are several ways of creating our transform functions. Let’s create some pure functions:

import std.stdio;
import std.ascii;
import std.algorithm.mutation;
import std.functional;

pure string reversed(string word) 
{
    return reverse(dup(word));
}

pure string capitalize(string word) 
{
    return toUpper(word[0]) ~ word[1 .. $];
}

pure string exclaim(string word) 
{
    return word ~ "!";
}

We cannot reverse an (immutable) string, so we have to duplicate (dup) it, before applying the reverse function from the std.algorithm.mutation module. The capitalize utilizes the toUpper function from the std.ascii module.

We can compose these functions using the compose! template from the std.functional module:

alias confuse = compose!(exclaim, capitalize, reversed);

void main()
{
    // Prints: "Looc!"
    writeln(confuse("cool"));
}

We can also pipe them using pipe! from the same module:

alias confuse = pipe!(reversed, capitalize, exclaim);

void main()
{
    // Prints: "Looc!"
    writeln(confuse("cool"));
}

It may not surprise us to see that pipe! is defined as compose! with its arguments reversed:

template unaryFun(alias fun, string parmName = "a") ;
alias compose(fun...) = unaryFun!(fun[0]);
alias pipe(fun...) = compose!(Reverse!fun);

Another interesting approach offered by D (and Nim) is the Uniform Function Call Syntax (UFCS):

pure string confuse(string word) 
{
    return word
        .reversed
        .capitalize
        .exclaim;
}

UFCS allows chaining free functions on a matching parameter. In this case, each function takes a string and outputs a string, so it can be applied as if it was a member function! Free functions are functions that are not in the local scope (in order to prevent naming conflicts).

A pipeline in JavaScript

The pipeline operator will probably look something like Elixir’s pipeline, although the TC39 has to figure out how to deal with un-parenthesized arrow functions, placeholder arguments and asynchronicity (async/await). One feature that could make this great is the ability to explicitize partial application of specified function arguments. There is an interesting presentation on the two competing proposals on the TC39 GitHub.

Let’s write our own pipeline function that confirms to these two ideas:

  1. the arguments for the functions do not have to be named (point-free)
  2. the composition order is the same as the application order

Let’s start off by writing a pipe function, that takes two arguments and puts the left argument into the right argument.

const pipe = (x, f) => f(x);

We can then rewrite our confuse function by piping our transformations:

const confuse = word => 
  pipe( 
    pipe(
      pipe(
        word,
        reverse
      ),
      capitalize
    ),
    exclaim
  );

That adheres to only one of our requirements (left-to-right order), so we need to find a way to generalize this function and make it a bit less explicit. We need to accept an arbitrary amount of functions. We want to apply each function to the result of the previous function.

In order to accept an arbitrary amount of functions, we could use a variadic argument. An imperative approach to this would be an iteration that applies pipe to every function.

const pipeline = (input, ...funcs) => {
  let result = input;
  
  for (const f of funcs) {
    result = pipe(result, f);
  }
  
  return result;
}

Our confuse function would then be:

const confuse = word => pipeline(word, reverse, capitalize, exclaim);

We can do better! First of all, the pipeline can be more declarative. After all, the pipeline is a reduction of applying pipe to each function with a given input:

const pipeline = (input, ...funcs) => 
    funcs.reduce(pipe, input);

Secondly, we can generalize it by creating a curried function: we translate our function that takes two arguments to a function that takes only one, but returns another function that takes the other argument. In effect, this allows deferred execution through partial application.

const pipeline = (...funcs) => input =>
    funcs.reduce(pipe, input);

This decouples the creation of the pipeline from its execution: invoking pipeline and giving it an amount of functions, produces a “preconfigured” pipeline. A function is returned that takes the input for the pipeline as its argument." Our pipeline is therefore a higher-order function: it takes function(s) as its arguments and/or it returns a function as its output.

Not only do we have a set of easily testable, generic and composable functions, we are more declarative through a point-free style:

const pipe = (x, f) => f(x);
const pipeline = (...funcs) => input => funcs.reduce(pipe, input);

const confuse = pipeline(reverse, capitalize, exclaim);

// Prints: "Looc!"
console.log(confuse("cool"));

As a bonus, we can define composition as the inverse of our pipeline, by reducing using pipe from right to left, rather than from left to right:

const pipe = (x, f) => f(x);
const composition = (...funcs) => input => funcs.reduceRight(pipe, input);

const confuse = composition(exclaim, capitalize, reverse);

// Prints: "Looc!"
console.log(confuse("cool"));

Interestingly, we have already seen this relationship between composition and pipelines in how D defined compose and pipe and how we can define our pipeline operator in Haskell by flipping the arguments of the composition operator. A more complete definition of pipe and compose is given in the Ramda library, a functional JavaScript library that contains loads of interesting building blocks for functional programming.

The minimal proposal

If we were to write our pipeline using the minimal proposal, the corresponding code would be as follows:

const confuse = reverse |> capitalize |> exclaim;

You might then even call confuse and console.log as a pipeline:

// Prints: Looc!
"cool" |> confuse |> console.log;

“Looc!”, indeed. If you wish to experiment with this, you can use a Babel plugin: @babel/plugin-proposal-pipeline-operator.

Risk

The benefit of easily composable and readable pipelines can also be its greatest risk. It is so easy to write oneliners composed of a lot of functions, that it will probably happen. This will not benefit the readability, thus maintainability, of our code. We can do a number of things to keep pipelines easy to read. These are things we should be doing already:

  1. break pipelines up into smaller pieces (these can even be pipelines themselves)
  2. use descriptive, intent-revealing names
  3. favor simple, unary functions over complex functions that take multiple arguments

Sometimes, we want composition

Is composition always more difficult to grok for people accustomed to left-to-right human languages than pipelines? No. There are, in fact, many functions that are more declaratively combined using composition.

For example, let’s define a function called odd, that determines whether a number is odd. Imagine that we already have the function even (i.e. checking if something is divisible by 2). We can define odd as something that is not even. In other words: the composition of not and even, wherein we want the function even to be evaluated before not.

In (future) JavaScript, this might look somewhat as follows:

// Pipeline
// i.e. const odd = pipeline(even, not);
const odd = even |> not;

// Composition
// i.e. const odd = composition(even, not);
const odd = not . even

To me, composition makes more sense in these kinds of cases as it is more aligned with real-world language. As I see it, whether to choose composition or a pipeline depends on which is the most readable and what the purpose of your operations is: a processing pipeline is easier to read from left to right, but the simple combination of functions might be more understandable through (right-to-left) composition.

In conclusion

JavaScript, like other languages, is becoming more and more functional. Although functional programming has a close relationship with mathematics, we do not need to accept that function composition is our only way of declaratively combining the behavior of functions.

Using a pipeline operator, we can not only define functions in a point-free manner, but we can also make our code more natural to reason about by composing our functions from left-to-right. This is where a pipeline operator (like Elixir’s or F#’s), like partial application, pattern matching and a composition operator, would be a nice addition to the (functional) JavaScript programmer’s toolbox.

Thoughts?

Leave a comment below!