Update (2022-03-25):
This original article is now out of date.
There is an
updated version of this article from 2022-03.
There are additionally some responses to this original article:
Original article
As of 2021-12, there are five early-stage
TC39 proposals that overlap in various
complex ways:
Multiple TC39 representatives have expressed concerns about redundancies
between these proposals—that the space as a whole needs to be
holistically considered, that goals need to be more specifically
articulated, and that there is not enough “syntax budget” in the
language to approve all of these proposals.
All five proposals touch the general space of
linear dataflow and data transformation—quick local
modification and data processing, without having to assign each
transformation step to a temporary variable. The general principles of
fluent dataflow are described in the pipe-operator explainer’s “why a pipe operator?” section. The principles can be summarized as so:
-
Natural word order and linear reading direction are important for code
readability.
-
Deeply nested expressions scramble word order, make the reading
direction switch between left to right and right to left, and
compromise their code’s readability.
-
It is important for developers to be able to unnest their deeply
nested expressions and improve the readability of their code.
-
Right now, these “fluent interfaces” are possible only with method chaining, where each method already
belongs to the prototype chain of its receiver. We should make the
benefits of fluent interfaces applicable to more code.
However, these principles leave several questions unanswered:
Which is more useful for dataflow: universal applicability or
prescriptivism?
The pipe operator tries to apply linear dataflows to as many kinds of
existing expressions as possible.
Bind-this and extensions are prescriptive in what expressions can be
used for dataflow (only function calls that use the
this
binding).
Function.pipe etc. are also prescriptive (only unary function calls).
Which dataflow expressions are most useful to optimize for
conciseness?
The pipe operator does not attempt to optimize for conciseness, beyond
allowing developers to avoid unnecessary temporary variables. It is
sometimes wordy with its topic reference #
. Its benefit is
flattening nested expressions and rearranging their word order, which
may outweigh conciseness concerns (while still being more concise than
temporary variables for each step).
Bind-this optimizes for conciseness of two function calls,
.bind()
and .call()
, which may be
two of the most frequently used calls in all JavaScript.
Extensions also optimizes for conciseness of .call()
(but
not .bind()
). It also optimizes for several other cases:
-
Conciseness of using unary function calls in certain circumstances
(with its polymorphic ternary syntax
arg0::ns:fn
, where
ns
is an object that is not a constructor and which
contains fn
).
-
Conciseness of property-descriptor extraction (with its
const ::{ prop } from obj;
syntax, equivalent to
prop = Object.getOwnPropertyDescriptor(obj, 'prop')
).
-
Conciseness of getting with property descriptors (
receiver::prop
is equivalent to receiver::prop.get()
).
-
Conciseness of setting with property descriptors (
receiver::prop = value
is equivalent to receiver::prop.set(value)
).
Function.pipe etc. optimize for conciseness of unary function calls.
Which expressions are not worth optimizing for conciseness,
due to syntactic complexity?
Each syntax adds additional complexity to the language; see also
The Tragedy of the Common Lisp. .bind()
, .call()
, property-descriptor
extraction/getting/setting, and unary function calls. Which of these are
not worth optimizing?
Are proposals that optimize for maximum applicability (like pipe)
compatible with proposals that are only narrowly applicable (like
bind-this and Function.pipe)? What about proposals that have more
intermediate breadth (like extensions)?
What is the relationship between the dataflow proposals and the PFA
operator? Does the PFA operator count as a dataflow proposal?
Each of these proposals answers these questions in a different way. The
following diagram compares each proposal’s answer in a variety of use
cases.
The pipe operator can apply any operation to an input value. This
includes the operations of nearly all other dataflow proposals. This
also includes numerous operations that are not possible with any other
dataflow proposal, such as the following:
- Pipe operator
value |> [ ...# ]
- Pipe operator
value |> [ # ]
- Pipe operator
value |> { key: # }
- Pipe operator
value |> `${ # }`
- Pipe operator
value |> new #
- Pipe operator
value |> new C(#, 0)
- Pipe operator
value |> await #
- Pipe operator
value |> yield #
Bind-this (and the pipe operator) can bind an input receiver object to a
function that belongs to another owner object:
- Bind-this
receiver::owner.method
- Pipe operator
receiver |> owner.method.bind(#)
Bind-this, partial application, and the pipe operator all can bind an
input receiver to a function that already belongs to the receiver:
- Bind-this
owner::owner.method
- Partial function application
owner.method~()
- Pipe operator
owner |> #.method.bind(#)
Bind-this, extensions, and the pipe operator all can call a function
with an input receiver object:
- Bind-this
receiver::owner.method(arg0)
- Extensions
receiver::(owner.method)(arg0)
- Pipe operator
receiver |> owner.method.call(#, arg0)
...and get an extracted property descriptor’s value from an input
receiver object:
- Bind-this
receiver::property.get()
- Extensions
receiver::property
- Pipe operator
receiver |> property.get.call(#)
…and set an extracted property descriptor’s value from an input receiver
object:
- Bind-this
receiver::property.set(value)
- Extensions
receiver::property = value
- Pipe operator
receiver |> property.set.call(#, value)
Function.pipe, Function.pipeAsync, Function.flow, and Function.flowAsync
are four simple helper functions that can apply or compose a series of
unary functions in a tacit (aka point-free) style. Function.pipe,
extensions, and the pipe operator all can sequentially call a series of
unary functions on a solitary input argument (although extensions only
does this when the function’s given “NS” owner is not a constructor):
- Function.pipe etc.
Function.pipe(arg0, NS.fn0, NS.fn1)
- Extensions
arg0::NS:fn0()::NS:fn1()
- Pipe operator
arg0 |> NS.fn0(#) |> NS.fn1(#)
Extensions and the pipe operator (but not Function.pipe) are also able
to call an n-ary function on an input argument, as long as it is the
zeroth argument:
- Extensions
arg0::NS:fn(arg1, arg2)
- Pipe operator
arg0 |> NS.fn(#, arg1, arg2)
The extensions proposal can extract property descriptors from an input
owner object using an import-like declaration syntax. This overlaps with
the pipe operator insofar that the pipe operator arguably improves the
word order of the extraction.
- Extensions
const ::{ property } from owner;
- Pipe operator
-
const property = owner |> Object.getOwnPropertyDescriptor(#,
'property');
The pipe operator alone (neither Function.pipe nor extensions) can call
an n-ary function on an input argument when it is not the zeroth
argument:
- Pipe operator
arg1 |> NS.fn(arg0, #, arg2)
Function.asyncPipe (and the pipe operator) can sequentially compose a
series of async unary functions:
- Function.pipe etc.
await Function.pipeAsync(arg0, NS.fn0, NS.fn1)
- Pipe operator
arg0 |> await NS.fn0(#) |> await NS.fn1(#)
In addition, Function.flow (and the pipe operator) can call a series of
unary functions on an solitary input argument:
- Function.pipe etc.
Function.flow(NS.fn0, NS.fn1)
- Pipe operator
-
arg0 => arg0 |> NS.fn0(#) |> await NS.fn1(#)
Among the dataflow proposals, only the pipe operator can call an n-ary
async function on an input argument:
- Pipe operator
arg0 |> await NS.fn(#, arg1, arg2)
- Pipe operator
arg1 |> await NS.fn(arg0, #, arg2)
(Although extensions originally also proposed a special variable
namespace for extracted properties, its champion has voiced willingness
to drop the special namespace, and it is not included in this article.)