A pipe operator for JavaScript: introduction and use cases

[2022-01-27] dev, javascript, es proposal
(Ad, please don’t block)

The proposal “Pipe operator (|>) for JavaScript” (by J. S. Choi, James DiGioia, Ron Buckton and Tab Atkins) introduces a new operator. This operator is an idea borrowed from functional programming that makes applying functions more convenient in many cases.

This blog post describes how the pipe operator works and what its use cases are (there are more than you might expect!).

The two competing proposals  

There originally were two competing proposals for a pipe operator, inspired by other programming languages:

  • F# by Microsoft is a functional programming language whose core is based on OCaml. This pipe operator works together well with curried functions (I’ll explain what that is soon).

  • Hack by Facebook is – roughly – a statically typed version of PHP. This pipe operator focuses on language features other than curried functions. It includes syntactic support for partial application.

The latter proposal won. However, if you prefer the F# pipe, there is good news: Its benefits may be added to the Hack pipe at a later time (details are explained later).

We’ll start by exploring the Hack pipe. Then we’ll move on to the F# pipe and compare its pros and cons with the pros and cons of the Hack pipe.

The Hack pipe operator  

This is an example of using the Hack pipe operator |>:

assert.equal(
  '123.45' |> Number(%), 123.45
);

The left-hand side of the pipe operator |> is an expression that is evaluated and becomes the value of the special variable %. We can use that variable on the right hand side. The returns the result of evaluating its right-hand side. In other words, the previous example is equivalent to:

assert.equal(
  Number('123.45'), 123.45
);

The following examples demonstrate that % really works like any other variable:

value |> someFunction(1, %, 3) // function calls
value |> %.someMethod() // method call
value |> % + 1 // operator
value |> [%, 'b', 'c'] // Array literal
value |> {someProp: %} // object literal
value |> await % // awaiting a Promise
value |> (yield %) // yielding a generator value

A first use case  

We’ll see more use cases later, but let’s quickly look at a core use case now. Consider these nested function calls:

const y = h(g(f(x)));

This notation usually does not reflect how we think about the computational steps. Intuitively, we’d describe them as:

  • Start with the value x.
  • Then apply f() to it.
  • Then apply g() to the result.
  • Then apply h() to the result.
  • Then assign the result to y.

The Hack pipe operator lets us express this intuition better:

const y = x |> f(%) |> g(%) |> h(%);

The F# pipe operator  

The F# pipe operator is roughly similar to the Hack pipe operator. However, it doesn’t have the special variable %. Instead, expects a function at its right-hand side and applies that function to its left-hand side. Therefore, the following two expressions are equivalent:

'123.45' |> Number
Number('123.45')

F# pipe is better at chaining unary (one-parameter) functions  

The following three statements are equivalent:

const y = h(g(f(x))); // no pipe
const y = x |> f(%) |> g(%) |> h(%); // Hack pipe
const y = x |> f |> g |> h; // F# pipe

We can see that Hack pipe is more verbose than F# pipe in this case.

Currying: important for the F# pipe operator but not a good fit for JavaScript  

F# pipe works well in functional programming languages with built-in support for currying.

What is currying? Normal (uncurried) functions can have zero or more parameters – for example:

const add2 = (x, y) => x + y;
assert.equal(
  add2(3, 5), 8
);

Curried functions have at most one parameter – they are unary. Functions with more parameters are emulated via unary functions that return functions:

const addCurry = x => y => x + y;
assert.equal(
  addCurry(3)(7), 10
);

Currying makes it easy to create functions where the initial arguments are partially applied (filled in). For example, these are three ways of defining the same function:

const f1 = addCurry(1);
const f2 = add2.bind(null, 1);
const f3 = x => add2(1, x);

With currying, piping into a function with more than one parameter is concise:

assert.equal(
  5 |> addCurry(1), 6
);

Alas, currying has downsides when used in JavaScript:

  • Currying can only fill in initial parameters. That works well in functional programming languages where the parameter with the data to operate on comes last (e.g. append(elem, list)). However, JavaScript’s functions are not structured that way and methods are often used, too.

  • I dislike using the same operator – function invocation – for both function calls and partial application: I prefer seeing immediately if a function is called or partially applied. With currying, I often can’t tell the difference – especially if I don’t know the signature of a function.

  • Currying doesn’t work with the named parameter pattern: We can’t curry a function that uses this pattern.

  • Currying doesn’t work with parameter default values: If omitting parameters triggers partial application, we can’t use it to trigger default values.

For more information, see “Currying is not idiomatic in JavaScript”.

The Hack pipe % performs partial application  

The % in Hack pipe expressions could be considered an operator for partial application. For example, the following two expressions are equivalent:

5 |> add2(1, %) // Hack pipe
5 |> $ => add2(1, $) // F# pipe

Hack pipe is better at: method calls, operators, literals, await, yield  

To use the following constructs, we need arrow functions:

value |> $ => $.someMethod() // method call
value |> $ => $ + 1 // operator
value |> $ => [$, 'b', 'c'] // Array literal
value |> $ => {someProp: $} // object literal

For await and yield, we’d need special syntax – e.g.:

value |> await // awaiting a Promise
value |> yield // yielding a generator value

F# pipe is better at destructuring  

With the F# pipe, we can use a unary function to destructure an input value:

const str = obj |> ({first,last}) => first + ' ' + last;

With the Hack pipe we have to either avoid destructuring:

const str = obj |> %.first + ' ' + %.last;

Or we have to use an immediately-invoked arrow function:

const str = obj |> (({first,last}) => first + ' ' + last)(%);

Should do-expressions ever be added to JavaScript, we could destructure via a variable declaration:

const str = obj |> do { const {first,last} = %; first + ' ' + last };

Use cases for the pipe operator  

There are three common kinds of use cases for the pipe operator:

  • Flat syntax for nested function calls
  • Post-processing values: Given a value, we can apply a function by only adding code after it – where normal function calls require code before and after the value.
  • Chaining for non-method language constructs

We’ll explore these use cases via Hack pipe, but they are also use cases for F# pipe.

Flat syntax for nested function calls  

Retrieving the prototype of a prototype of an object  

All iterators created by the JavaScript standard library have a common prototype. That prototype is not directly accessible, but we can retrieve it like this:

const IteratorPrototype =
  Object.getPrototypeOf(
    Object.getPrototypeOf(
      [][Symbol.iterator]()
    )
  )
;

With the pipe operator, the code becomes easier to understand:

const IteratorPrototype =
  [][Symbol.iterator]()
  |> Object.getPrototypeOf(%)
  |> Object.getPrototypeOf(%)
;

Mixin classes  

Mixin classes are a pattern where we use functions as factories for subclasses to emulate multiple inheritance. For example, these are two mixin classes:

const Parsable = (Sup) => class extends Sup {
  // ···
};
const Printable = (Sup) => class extends Sup {
  // ···
};

Both return a subclass of a given class Sup. Without the pipe operator, these mixins are used as follows:

class Block extends Printable(Parsable(Object)) {
  // ···
}

With the pipe operator, we get:

class Block extends Object |> Parsable(%) |> Printable(%) {
  // ···
}

Post-processing values  

With the pipe operator, we can write functions such as myProcessor that post-process values in some manner:

const myPostProcessedValue = myValue |> myProcessor(%);

We can see that the Hack pipe is more verbose here than the F# pipe would be. More on that later.

Easy-to-remove logging of values  

Consider the following function:

function myFunc() {
  // ···
  return someObject.someMethod();
}

How would we change this code to log the result of someObject.someMethod() before it is returned?

Without the pipe operator, we’d have to introduce a temporary variable or wrap a function call around the operand of return.

With the pipe operator, we can do this:

function myFunc() {
  // ···
  return theResult |> (console.log(%), %); // (A)
}

In line A, we used the comma operator. To evaluate the following expression:

(expr1, expr2)

JavaScript first evaluates expr1 and then expr2 and then returns the latter result.

Post-processing functions  

In the following code, the value that we post-process is a function – we add a property to it:

const testPlus = () => {
  assert.equal(3+4, 7);
} |> Object.assign(%, {
  name: 'Test the plus operator',
});

The previous code is equivalent to:

const testPlus = () => {
  assert.equal(3+4, 7);
}
Object.assign(testPlus, {
  name: 'Testing +',
});

We could also have used the pipe operator like this:

const testPlus = () => {
  assert.equal(3+4, 7);
}
|> (%.name = 'Test the plus operator', %)
;

Alternative to tagged templates  

Tagged templates are one way of post-processing template literals. The pipe operator can also do that:

const str = String.raw`
  Text with
  ${3} indented
  lines
` |> dedent(%) |> prefixLines(%, '> ');

Note that template literals have access to more data than functions we apply via pipe. They are therefore considerably more powerful – for example, String.raw can only be done via a template literal.

If a pipe-triggered function call is enough, we get the benefit of being able to apply multiple post-processing operations at the same time and can even combine those with a template literal (as is done in the example).

Chaining for non-method language constructs  

Method-like chaining  

Thanks to the pipe operator, we can chain operations similarly to how we can chain method invocations:

const regexOperators =
  ['*', '+', '[', ']']
  .map(ch => escapeForRegExp(ch))
  .join('')
  |> '[' + % + ']'
  |> new RegExp(%)
;

This code is easy to read and less verbose than introducing intermediate variables.

Chaining function calls  

We can chain methods such as the Array methods .filter() and .map(). However:

  • They are a fixed set of operations that is built into a class. There is no way to add more Array methods via a library. (A library could create a subclass but that doesn’t help us if we get an Array from somewhere else.)
  • Tree-shaking (dead code elimination) is difficult if not impossible with methods.

With the pipe operator, we can chain functions as if they were methods – without the two aforementioned downsides:

import {Iterable} from '@rauschma/iterable/sync';
const {filter, map} = Iterable;

const resultSet = inputSet
  |> filter(%, x => x >= 0)
  |> map(%, x => x * 2)
  |> new Set(%)
;

Summary: Hack pipe vs. F# pipe  

Strengths of F# pipe:

  • Is better if we have code that uses currying.
  • Is less verbose when working with unary functions – e.g. when post-processing values.
  • Destructuring is slightly easier.

Strengths of Hack pipe:

  • Works better with typical (uncurried) JavaScript code and functions with an arity higher than 1.
  • Supports await and yield (without special syntax).

TC39 is currently only persuing the Hack pipe. Concerns against F# pipe include:

  • Memory performance (due to the creation and invocation of functions)
  • Difficult to make await and yield work
  • Might encourage a split in the ecosystem between code that uses currying and code that doesn’t.

How likely is it that JavaScript gets a pipe operator?  

Progress is being made on the Hack pipe, but F# is not being persued anymore (for details, see the Hack operator proposal).

Potential improvements for Hack pipe and F# pipe  

In this section, we examine ways in which the two operators could be improved. However, it’s not always clear if the added complexity would be worth it.

F# pipe: better support for functions with arities greater than 1  

If JavaScript had a partial application operator (as proposed here), then using F# pipes would look almost the same as using Hack pipes:

// Hack pipe
const resultArray = inputArray
  |> filter(%, str => str.length >= 0)
  |> map(%, str => '['+str+']')
  |> console.log(%)
;

// F# pipe with partial application operator
const resultArray = inputArray
  |> filter~(?, str => str.length >= 0)
  |> map~(?, str => '['+str+']')
  |> console.log
;

A few downsides remain, though:

  • Memory performance doesn’t improve (we still need function calls).
  • Doesn’t work with operators such as +.
  • Doesn’t work with await and yield.

Smart pipelines: Hack pipes with optional %  

We could make Hack pipe less verbose in the unary case:

  • Is there a % in the right-hand side of the |> operator?
  • If yes, then |> works like the Hack pipe.
  • If not, then |> works like the F# pipe.

Example:

const resultArray = inputArray
  |> filter(%, str => str.length >= 0)
  |> map(%, str => '['+str+']')
  |> console.log // (A)
;

Note that line A is different than the normal Hack pipe.

A proposal for this approach has been discarded, but it could be revived as an add-on for Hack pipes.

An extra operator for unary functions that complements the Hack pipe  

We could complement the Hack pipe with a special operator |>> for unary functions that works like the F# pipe (line A):

const resultArray = inputArray
  |> filter(%, str => str.length >= 0)
  |> map(%, str => '['+str+']')
  |>> console.log // (A)
;

Do we really need a pipe operator? What are the alternatives?  

In this section, we look at alternatives to the pipe operator. They are quite elegant but all have the following downsides:

  • They are only good at chaining, not at post-processing.
  • Even if verbosity is only a little increased per processing step, it adds up and makes a built-in pipe operator more convenient.
import {Iterable} from '@rauschma/iterable/sync';
const {filter, map} = Iterable;

const resultSet = inputSet
  |> filter(%, x => x >= 0)
  |> map(%, x => x * 2)
  |> new Set(%)
;

Function.pipe()  

One alternative to a pipe operator is to use a function – for example, the proposed Function.pipe():

const resultSet = Function.pipe(
  inputSet,
  $ => filter($, x => x >= 0)
  $ => map($, x => x * 2)
  $ => new Set($)
);

Thanks to arrow functions, this approach is less verbose than we might expect.

Its downsides are:

  • We can’t use await and yield.
  • Functions are created and invoked.

Using intermediate variables  

We could use intermediate variables:

const filtered = filter(inputSet, x => x >= 0);
const mapped = map(filtered, x => x * 2);
const resultSet = new Set(mapped);

On one hand, that’s more verbose than piping. On the other hand, the variable names describe what is going on – which can be useful if a step is complicated.

Reusing a variable multiple times  

The following technique is a variation of the previous technique – it reuses a short variable name such as $:

let $ = inputSet;
$ = filter($, x => x >= 0);
$ = map($, x => x * 2);
const resultSet = new Set($);

(I’m not sure who deserves credit for this technique; I’ve first seen it used here.)

We save characters due to the shorter variable name and because we don’t need one variable declaration per step.

A downside of this technique is that it can’t be used multiple times within the same scope. And there is no simple way of wrapping a code block around such a code fragment, either.

However, we can fix that via an immediately-invoked Arrow function:

const resultSet = (
  ($ = inputSet) => {
    $ = filter($, x => x >= 0);
    $ = map($, x => x * 2);
    $ = new Set($);
    return $;
  }
)();

Props to @emnudge for suggesting using a parameter default value.

Share your use cases!  

Is there an interesting use case for the pipe operator that I missed? Let us know in the comments.

Further reading