Pipe Operator (|>
) for JavaScript
- Stage: 2
- Champions: J. S. Choi, James DiGioia, Ron Buckton, Tab Atkins-Bittner, [list incomplete]
- Former champions: Daniel Ehrenberg
- Specification
- Contributing guidelines
- Proposal history
- Babel plugin: Implemented in v7.15. See Babel documentation.
(This document uses %
as the placeholder token for the topic reference.
This will almost certainly not be the final choice;
see the token bikeshedding discussion for details.)
Why a pipe operator
In the State of JS 2020 survey, the fourth top answer to “What do you feel is currently missing from JavaScript?” was a pipe operator. Why?
When we perform consecutive operations (e.g., function calls) on a value in JavaScript, there are currently two fundamental styles:
- passing the value as an argument to the operation (nesting the operations if there are multiple operations),
- or calling the function as a method on the value (chaining more method calls if there are multiple methods).
That is, three(two(one(value)))
versus value.one().two().three()
.
However, these styles differ much in readability, fluency, and applicability.
Deep nesting is hard to read
The first style, nesting, is generally applicable –
it works for any sequence of operations:
function calls, arithmetic, array/object literals, await
and yield
, etc.
However, nesting is difficult to read when it becomes deep: the flow of execution moves right to left, rather than the left-to-right reading of normal code. If there are multiple arguments at some levels, reading even bounces back and forth: our eyes must jump left to find a function name, and then they must jump right to find additional arguments. Additionally, editing the code afterwards can be fraught: we must find the correct place to insert new arguments among many nested parentheses.
Real-world example
Consider this real-world code from React.
console.log(
chalk.dim(
`$ ${Object.keys(envars)
.map(envar =>
`${envar}=${envars[envar]}`)
.join(' ')
}`,
'node',
args.join(' ')));
This real-world code is made of deeply nested expressions. In order to read its flow of data, a human’s eyes must first:
-
Find the initial data (the innermost expression,
envars
). -
And then scan back and forth repeatedly from inside out for each data transformation, each one either an easily missed prefix operator on the left or a suffix operators on the right:
Object.keys()
(left side),.map()
(right side),.join()
(right side),- A template literal (both sides),
chalk.dim()
(left side), thenconsole.log()
(left side).
As a result of deeply nesting many expressions (some of which use prefix operators, some of which use postfix operators, and some of which use circumfix operators), we must check both left and right sides to find the head of each expression.
Method chaining is limited
The second style, method chaining, is only usable if the value has the functions designated as methods for its class. This limits its applicability. But when it applies, thanks to its postfix structure, it is generally more usable and easier to read and write. Code execution flows left to right. Deeply nested expressions are untangled. All arguments for a function call are grouped with the function’s name. And editing the code later to insert or delete more method calls is trivial, since we would just have to put our cursor in one spot, then start typing or deleting one contiguous run of characters.
Indeed, the benefits of method chaining are so attractive that some popular libraries contort their code structure specifically to allow more method chaining. The most prominent example is jQuery, which still remains the most popular JS library in the world. jQuery’s core design is a single über-object with dozens of methods on it, all of which return the same object type so that we can continue chaining. There is even a name for this style of programming: fluent interfaces.
Unfortunately, for all of its fluency,
method chaining alone cannot accommodate JavaScript’s other syntaxes:
function calls, arithmetic, array/object literals, await
and yield
, etc.
In this way, method chaining remains limited in its applicability.
Pipe operators combine both worlds
The pipe operator attempts to marry the convenience and ease of method chaining with the wide applicability of expression nesting.
The general structure of all the pipe operators is
value |>
e1 |>
e2 |>
e3,
where e1, e2, e3
are all expressions that take consecutive values as their parameters.
The |>
operator then does some degree of magic to “pipe” value
from the lefthand side into the righthand side.
Real-world example, continued
Continuing this deeply nested [real-world code from React][react/scripts/jest/jest-cli.js]:
console.log(
chalk.dim(
`$ ${Object.keys(envars)
.map(envar =>
`${envar}=${envars[envar]}`)
.join(' ')
}`,
'node',
args.join(' ')));
…we can untangle it as such using a pipe operator
and a placeholder token (%
) standing in for the previous operation’s value:
Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${%}`
|> chalk.dim(%, 'node', args.join(' '))
|> console.log(%);
Now, the human reader can rapidly find the initial data
(what had been the most innermost expression, envars
),
then linearly read, from left to right,
each transformation on the data.
Temporary variables are often tedious
One could argue that using temporary variables should be the only way to untangle deeply nested code. Explicitly naming every step’s variable causes something similar to method chaining to happen, with similar benefits to reading and writing code.
Real-world example, continued
For example, using our previous modified [real-world example from React][react/scripts/jest/jest-cli.js]:
Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${%}`
|> chalk.dim(%, 'node', args.join(' '))
|> console.log(%);
…a version using temporary variables would look like this:
const envarString = Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ');
const consoleText = `$ ${envarString}`;
const coloredConsoleText = chalk.dim(consoleText, 'node', args.join(' '));
console.log(coloredConsoleText);
But there are reasons why we encounter deeply nested expressions in each other’s code all the time in the real world, rather than lines of temporary variables. And there are reasons why the method-chain-based fluent interfaces of jQuery, Mocha, and so on are still popular.
It is often simply too tedious and wordy to write code with a long sequence of temporary, single-use variables. It is arguably even tedious and visually noisy for a human to read, too.
If naming is one of the most difficult tasks in programming, then programmers will inevitably avoid naming variables when they perceive their benefit to be relatively small.
Reusing temporary variables is prone to unexpected mutation
One could argue that using a single mutable variable with a short name would reduce the wordiness of temporary variables, achieving similar results as with the pipe operator.
Real-world example, continued
For example, our previous modified [real-world example from React][react/scripts/jest/jest-cli.js] could be re-written like this:
let _;
_ = Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ');
_ = `$ ${_}`;
_ = chalk.dim(_, 'node', args.join(' '));
_ = console.log(_);
But code like this is not common in real-world code. One reason for this is that mutable variables can change unexpectedly, causing silent bugs that are hard to find. For example, the variable might be accidentally referenced in a closure. Or it might be mistakenly reassigned within an expression.
Example code
// setup
function one () { return 1; }
function double (x) { return x * 2; }
let _;
_ = one(); // _ is now 1.
_ = double(_); // _ is now 2.
_ = Promise.resolve().then(() =>
// This does *not* print 2!
// It prints 1, because `_` is reassigned downstream.
console.log(_));
// _ becomes 1 before the promise callback.
_ = one(_);
This issue would not happen with the pipe operator. The topic token cannot be reassigned, and code outside of each step cannot change its binding.
let _;
_ = one()
|> double(%)
|> Promise.resolve().then(() =>
// This prints 2, as intended.
console.log(%));
_ = one();
For this reason, code with mutable variables is also harder to read. To determine what the variable represents at any given point, you must to search the entire preceding scope for places where it is reassigned.
The topic reference of a pipeline, on the other hand, has a limited lexical scope, and its binding is immutable within its scope. It cannot be accidentally reassigned, and it can be safely used in closures.
Although the topic value also changes with each pipeline step, we only scan the previous step of the pipeline to make sense of it, leading to code that is easier to read.
Temporary variables must be declared in statements
Another benefit of the pipe operator over sequences of assignment statements (whether with mutable or with immutable temporary variables) is that they are expressions.
Pipe expressions are expressions that can be directly returned, assigned to a variable, or used in contexts such as JSX expressions.
Using temporary variables, on the other hand, requires sequences of statements.
Examples
Pipelines | Temporary Variables |
---|---|
|
|
|
|
Why the Hack pipe operator
There were two competing proposals for the pipe operator: Hack pipes and F# pipes. (Before that, there was a third proposal for a “smart mix” of the first two proposals, but it has been withdrawn, since its syntax is strictly a superset of one of the proposals’.)
The two pipe proposals just differ slightly on what the “magic” is,
when we spell our code when using |>
.
Both proposals reuse existing language concepts: Hack pipes are based on the concept of the expression, while F# pipes are based on the concept of the unary function.
Piping expressions and piping unary functions correspondingly have small and nearly symmetrical trade-offs.
This proposal: Hack pipes
In the Hack language’s pipe syntax,
the righthand side of the pipe is an expression containing a special placeholder,
which is evaluated with the placeholder bound to the result of evaluating the lefthand side's expression.
That is, we write value |> one(%) |> two(%) |> three(%)
to pipe value
through the three functions.
Pro: The righthand side can be any expression, and the placeholder can go anywhere any normal variable identifier could go, so we can pipe to any code we want without any special rules:
value |> foo(%)
for unary function calls,value |> foo(1, %)
for n-ary function calls,value |> %.foo()
for method calls,value |> % + 1
for arithmetic,value |> [%, 0]
for array literals,value |> {foo: %}
for object literals,value |> `${%}`
for template literals,value |> new Foo(%)
for constructing objects,value |> await %
for awaiting promises,value |> (yield %)
for yielding generator values,value |> import(%)
for calling function-like keywords,- etc.
Con: Piping through unary functions
is slightly more verbose with Hack pipes than with F# pipes.
This includes unary functions
that were created by function-currying libraries like Ramda,
as well as unary arrow functions
that perform complex destructuring on their arguments:
Hack pipes would be slightly more verbose
with an explicit function call suffix (%)
.
(Complex destructuring of the topic value will be easier when [do expressions][] progress, as you will then be able to do variable assignment/destructuring inside of a pipe body.)
Alternative proposal: F# pipes
In the F# language’s pipe syntax,
the righthand side of the pipe is an expression
that must evaluate into a unary function,
which is then tacitly called
with the lefthand side’s value as its sole argument.
That is, we write value |> one |> two |> three
to pipe value
through the three functions.
left |> right
becomes right(left)
.
This is called tacit programming or point-free style.
Real-world example, continued
For example, using our previous modified [real-world example from React][react/scripts/jest/jest-cli.js]:
Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${%}`
|> chalk.dim(%, 'node',