TC39 Pipeline Operator - Hack vs F#
JavaScriptFunctional ProgrammingTC39I want to take some time to share everything I know about the pipeline operator proposal that is currently in stage 2, to the best of my ability. This will, of course, be a somewhat biased account — it's my article, haha — but I'll do my best to present both sides while presenting my case. Also, keep in mind, that as thorough as I'll try to be, I'm completely sure I'm missing things. If you spot something or think of something, feel free to DM me on Twitter. I try to answer all of my DMs.
IMPORTANT: Before we get into this, there are folks, myself included, that have VERY strong feelings about these matters. Please keep those feelings in check, the folks that are working on these standards are good people trying to do right by the community, even if we disagree with them.
What Is A "Pipeline Operator"? #
Simply put, the pipeline is an operator, |>
in this case, that allows a software developer to "pipe" the evaluated expression from the left-hand-side (LHS) to some function or expression on the right-hand-side (RHS). There are several implementations and examples of this throughout the programming world in various languages, and we're in the amazing position that the TC39 — the governing body of the ECMAScript standard on which JavaScript is based — is considering adding such an operator to the ECMAScript standard (And therefor JavaScript). There were many competing proposals, two stood out the most, the Hack pipeline — currently in stage 2, and the F# pipeline — skipped despite being very popular.
Piping Functions (now) #
The first thing to understand is what is really means to pipe functions. In today's JavaScript, there's really only one way to "pipe" functions: With a "functional pipe". The functional pipe is a common functional programming utility that exists in libraries like Ramda and RxJS, as well as many others.
The idea behind a functional pipe is to apply a series of functions, in the order they were specified, passing the return value of each function to the next function in the chain. The utility functions that do this are relatively simple:
js
functionpipe (initialArg , ...fns ) {returnfns .reduce ((prevValue ,fn ) =>fn (prevValue ),initialArg );}// Some basic use:constresult =pipe (2,(x ) =>x ** 2,(x ) =>x - 1,);// result: 3
One powerful feature of functional pipes is the fact that functions are portable and composable. So we can change the above example to be more readable — and have more reusable parts — like so:
js
functionsquared (x ) {returnx ** 2;}functionsubtractOne (x ) {returnx - 1;}// Refined useconstresult =pipe (2,squared ,subtractOne );// result: 3
Piping Functions: Use Cases #
Of course, use cases for piping functions are generally much more elaborate than simple math. The most common use cases are usually about allowing functions to be applied repeatedly over various sets of things. A standout example — that of course I'm going to talk about — is found in RxJS.
RxJS uses piped functions in order to transform observables. Years ago, RxJS only had class methods on their Observable
type in order to transform observables. This worked generally well, but with so many possible operations and methods for observables, and the fact that methods can't really be "tree-shaken" by modern bundlers, it was found that having so many methods wasn't good for the community that used RxJS. We tried what is called "prototype patching", where modules would add methods to Observable
, "ala carte", but that came with a host of other issues (I'll try to address all of that in another post). Ultimately, we settled on using piped functions. The benefit of this is you only "pay for what you use" in bundle size. In other words, you import and use just the operators you need, and the rest can be "tree-shaken" away.
ts
// RxJS 5.5 and lower (methods and no piping):source$ .filter ((x ) =>x % 2 === 0).map ((x ) =>x *x ).concatMap ((x ) =>of (x + 1,x + 2,x + 2,x + 4)).subscribe (console .log );// RxJS 5.5 and higher (with piped functions):source$ .pipe (filter ((x ) =>x % 2 === 0),map ((x ) =>x *x ),concatMap ((x ) =>of (x + 1,x + 2,x + 2,x + 4)),).subscribe (console .log );
In RxJS's case, a simple pipe
function — as shown in the first example in the article — is used inside of the pipe
method on Observable
, passing this
as the initial argument.
All of the operators, filter
, map
, and concatMap
, are then created via higher-order functions. Functions that take arguments necessary to set up their actions, and return in this case, unary pipeable functions of the shape (source: Observable<In>) => Observable<Out>
. (Unary functions are functions that take a single argument and return a value)
This means the overall implementation of the RxJS functions are necessarily complex in terms of functional composition. They're all basically (arg) => (source) => result
such that (source) => result
can be piped with the functional pipe.
The Hack Pipeline (The TC39's current proposal) #
The current pipeline operator the TC39 is proceeding with is called the Hack pipeline. It is so-named after the Hack language, a PHP dialect created at Facebook, that has a pipeline operator this is modeled after. It's worth noting that this proposal is only in stage 2, and what I'm writing here is the state of the proposal at this time, to the best of my knowledge.
With the Hack pipeline, the value from the LHS is provided to the expression on the RHS with a special character, currently the ^
character, although past variations used %
.
Some examples:
ts
// Similar to our first example.// Short and sweet! (If a little hard on the eyes)const result = 2 |> ^ ** 2 |> ^ - 1;// The same thing only using functions:const result = 2|> squared(^)|> subtractOne(^);// RxJS using the hack pipeline:// Note the (^) after each Rx operator.source$|> filter((x) => x % 2 === 0)(^) // <-- here|> map((x) => x * x)(^)|> concatMap((x) => of(x + 1, x + 2, x + 2, x + 4))(^)|> ^.subscribe(console.log);// An example of using existing non-unary function APIs:const randomNumberBetween20And50 = Math.random() * 100|> Math.pow(^, 2)|> Math.min(^, 50)|> Math.max(20, ^)// An example of use within an async functionasync function demo() {return await fetch(url)|> await ^.json()|> await delayValue(1000, ^)}// Throwing within a step of the pipe (see "Cons" below):const validNumberBetween20and50 = maybeGetNumber()|> ((num) => {if (Number.isNaN(+num)) {throw new TypeError('Did not get a number')}return num;})(^)|> Math.pow(^, 2)|> Math.min(^, 50)|> Math.max(20, ^)
Pros #
- Explicit: Many of the proponents of the hack pipeline like that it is more explicit than the F# pipeline, being that you can plainly see what is executed.
- Does not require higher-order functions: Another advantage is since the value from the LHS is being provided as a special character to the expression on the RHS, there is no need to create a higher-order function to use the value.
- Works with any existing function: Proponents of the hack pipeline also point out that the hack pipeline can be used with any existing JavaScript function without any additional work.
- Most expressions will "just work": If you want to pipe to
^ + ^
, you can do that. - Can await/yield from surrounding context: If the
|>
is inside anasync function
, then you canawait
from within it. Likewise, if used inside of a generator or async generator, the RHS of|>
can have a yield expression.
Cons #
- Magic character cannot be renamed: In the current proposal, there is no way to rename the
^
, short of wrapping the RHS in a function. - Magic character must be found (it's explicit): Where the value from the LHS is used is entirely up to the developer and where they place the
^
. This means in your average text editor, you might have to play "find the caret" to figure out where the value is being used. - Some expressions just won't work: While you can pipe to most expressions, obviously some expressions will not work — for example an expression with another
|>
in them, as an example. - Doesn't work cleanly with any existing functionally piped libraries: Probably the most important con of the Hack pipeline, in my opinion. The libraries that worked hard to popularize piping functions won't benefit as much from the Hack pipeline operator — as an example RxJS would need to call map like
source$ |> map(fn)(^)
. - No straight-forward way to throw within the an expression without a function: There's a lot of nuance here, but basically, if you decide that you can't add whatever your value is with
^ + ^
, there's not a clean mechanism with which to throw a type error, unless you wrap your addition in a function or maybe brackets, if those are allowed (nothing is specified there). This might become especially confusing in async contexts. - Could kill the partial application proposal. It would probably be confusing to have two similar but different pieces of functionality in the language. Partial application is very similar, in that it uses a magic character to apply a value to an expression, returning a function that takes the same number of arguments as the count of the magic character use. (It's really cool, if a little confusing.)
- It's barely different than using
let
and=
. This is sort of hard to articulate without showing code. In short, it's a slightly more efficient way of doing something like what you see below. (Special thanks to @oskari_noppa for pointing out how to get this to work well in TypeScript)
ts
// Hackconst a = 2|> squared(^)|> subtractOne(^);// The same with let and equals// Amazingly, this works with TypeScript quite well.// (if x is `any` it infers in each step)let x;x = 2;x = squared(x);x = subtractOne(x);// Another pattern with separate declarations in a chain.// This has the added advantage that you can use each part// throughout the chainconst a = 2,b = squared(a),c = subtractOne(b),d = a + b + c; // Can't do this with the Hack pipeline
The F# Pipeline #
The F# pipeline is another variant of the pipeline proposal that seemed to have a lot of traction to those external to the TC39, but was skipped in favor of the Hack pipeline, mostly citing reasons in the "pros" for Hack pipeline above. It comes, as you may have guess, from a few languages, but the most notable implementation is in F#. Thus the name.
The idea with the F# pipeline is that it passes the value from the LHS as the last argument to the function on the RHS. In this way, it works best with unary functions exactly like those that work with functional piping above.
Some examples:
ts
// Similar to our first example.const result = 2|> (n) => n ** 2|> (n) => n - 1;// The same thing only using functions:const result = 2|> squared|> subtractOne// RxJS using the F# pipeline:source$|> filter((x) => x % 2 === 0)|> map((x) => x * x)|> concatMap((x) => of(x + 1, x + 2, x + 2, x + 4))|> result$ => result$.subscribe(console.log);// An example of using existing non-unary function APIs:const randomNumberBetween20And50 = Math.random() * 100|> randomNum => Math.pow(randomNum, 2)|> squared => Math.min(squared, 50)|> atLeast50 => Math.max(20, atLeast50)// An example of use within an async function// NO EQUIVALENT, TMK. You'd just do what you've always done.async function demo() {const response = await fetch(url);const data = await response.json();return await delayValue(1000, data);}// Throwing within a step of the pipeconst validNumberBetween20and50 = maybeGetNumber()|> (num) => {if (Number.isNaN(+num)) {throw new TypeError('Did not get a number')}return num;}|> validNum => Math.pow(validNum, 2)|> squared => Math.min(squared, 50)|> atLeast50 => Math.max(20, atLeast50)
Pros #
- Implicit: Passes the value from the LHS to the function on the RHS in a predictable way implicitly. There's no question about where the value went or why, it's always passed as the last argument to the function on the right. In the most common case of a unary function, it's the only argument.
- Works with existing functionally pipeable functions/libraries. OOTB, the entire JavaScript community that has wanted to pipe functions, and has been piping functions, will benefit from this new operator. For example, RxJS will be able to call map like
source$ |> map(fn)
. - Works with any existing function using arrows. Using arrow functions with the F# pipeline operator allows developers to use any existing API in the exact same manner they can with the Hack pipeline, only with the added bonus that they can name the value.
- No magic character required. There's no need to utilize or add a special character to JavaScript. By using arrow functions, you just use regular arguments.
- Developers familiar with functional programming will find additional power. Anyone that knows how to create a higher order unary function (e.g.
(...args) => (in) => out
) can create interesting, reusable patterns that aren't available with the Hack pipeline. - Some interesting patterns with await/yield. With the F# pipeline, developers can asynchronously get a function to receive a value from the LHS using
value |> await getSomeFunc()
. Similarly, in a generator function, a developer may be able to get a function reference to execute through a coroutine withyield
. - RHS code is generally reusable. Unlike the Hack pipeline, anything on the RHS of the F# pipeline operator can be put in a variable and reused, because it must evaluate to an actual function reference.
- Works great with records/tuples (now in Stage 2): Unlike the Hack pipeline, users will be able to simply destructure records and tuples on the RHS using common arrow functions. From what I can tell, there's no plan in place for how to deal with destructuring from the magic character in the Hack pipeline. It might even be possible to leverage tuples to call functions on the RHS that take multiple arguments, depending on design decisions.
- Works great with partial application (now in Stage 1): The partial application proposal adds all of the nice-to-haves from the Hack pipeline to the F# pipeline, and truly provides the best of both worlds. It's my belief that if partial application already existed, the Hack pipeline wouldn't even be on the table.
Cons #
- Requires the use of a function on the RHS. Most commonly, and simply, it will be an arrow function, however it may be a higher-order function if someone wants to reuse it.
- Doesn't allow the same sort of await/yield use. While it does allow the use of both, they would need to resolve to, or yield, a function reference. It's not a total limitation, but it is different. In either case, it's not completely apparent how useful awaiting after a pipeline will even be, especially considering that error handling within the RHS expression would require a function for both F# or Hack at this point.
- Advanced use will proliferate higher-order functions. This is seen as a con by some, so it's worth noting here. Higher-order functions add a new sort of complexity for some people. I personally think it will be the catalyst for a better understanding of higher order functions, which are really just a different way to close over state with a function than say, a class and method. Either way, it's definitely more complex than a single, plain function. Just keep in mind that simple, plain functions will still "just work" using an arrow function. e.g.
|> (x) => plainFunc(x, x)
.
Summary #
I know this was flavored a lot with opinion, but hopefully it's enough to get a few more people thinking about this problem, and it's enough to get the very important and smart people working on this proposal to stop and reconsider some of the decisions that were made thus far. I think it's a shame that the proposal has proceeded to stage 2 in its current state.
It's definitely possible to get the best of both worlds either by: 1) Allowing the Hack Pipeline to implicitly act like the F# pipeline when the magic character is not present, or 2) Switch to the F# pipeline, and also land the partial application proposal. On paper, option 2 would, by far, provide the most powerful set of tools to JavaScript developers.
I've ranted about this particular thing for a while, for sure. I'm trying to do what I think is best for the JavaScript community with whatever little influence I have. Honestly, if the Hack proposal would "just work" with pipeable unary functions, I'd have absolutely no beef with it, and this entire article would not be written.
I hope that people on both sides of the debate find the information here useful. I also hope we can resolve things such that there is no "both sides" and instead we're all moving forward together.