JSChoi.org

Comparison of JavaScript dataflow proposals

J. S. Choi
2021-12-24

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:

  1. Natural word order and linear reading direction are important for code readability.
  2. 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.
  3. It is important for developers to be able to unnest their deeply nested expressions and improve the readability of their code.
  4. 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:

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; see their regions for more information. 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.)