Skip to content

Hack is dead. Long live F#. #205

@kiprasmel

Description

@kiprasmel

I would like to thoroughly discuss the pros & cons of the F# vs Hack & other proposals.

I will have questions at the end that I would like answered by the committee & others involved.


I want to argue that the method for choosing the proposal (F# vs Hack) is wrong.

In @tabatkins discussion on the differences of the proposals, their conclusion on the differences is this:

Ultimately... very little [differences]. <...> A three-character tax on RHSes in various situations.

I disagree. There are big differences between the proposals, and they cannot be measured mearily by the amount of characters. Let's dive in.

1. Function composition

I'll start from the async/await & yield argument against F#:

With Hack, you can do this:

fetch("/api/v1/user")
 |> await ^.json()
 |> await ^.name
 |> console.log(^)

with F#:

fetch("/api/v1/user")
 |> await
 |> res => res.json()
 |> await
 |> user => user.name
 |> console.log

with F#, you must have the await keyword on a new line/pipe, meanwhile with Hack you can have it at the same line.

I argue that F# is better than Hack because in the Hack example, you are doing 2 things at once: taking the res.json (which is further hidden by the ^ or whatever token), AND ALSO awaiting the promise. It's a bigger cognitive load, as opposed to the F#'s way where only 1 thing is done per 1 line/pipe.

As mentioned by @runarberg, the superiority of Hack over F# regarding async/await is very questionable - the claims are not backed up in the current proposal's README and further documents, and as a given example, the Rust's community has, after a very thorough RFC, went with the same exact solution that F#, not Hack, would allow.

Discussed further in #204


Another point by @OliverJAsh is that for Promises, they are already "pipe-able" via .then, and that it is completely possible to live without the async/await support in pipeline operators:

fetch("/api/v1/user")
 .then(res => res.json())
 .then(user => user.name)
 .then(console.log)

And going even further, you could say we don't need pipeline operators at all, because they're already here (the F# way):

["name"]
 .map((name) => name.toUpperCase())
 .map((name) => "hello, " + name)
 .map((name) => name + "!")
 .map((name) => console.log(name));
// hello, NAME!

Promise.resolve("name")
 .then((name) => name.toUpperCase())
 .then((name) => "hello, " + name)
 .then((name) => name + "!")
 .then(console.log);
// hello, NAME!

// F#:
"name"
 |> (name) => name.toUpperCase()
 |> (name) => "hello, " + name
 |> (name) => name + "!"
 |> console.log
// hello, NAME!

this is possible, because both [].map and Promise.then are, in FP terminology, Functors. See a quick explainer. In essence, they preserve function composition, e.g. g(f(x)) is the same as x |> f |> g, which is fundamentally what the pipeline operators are about!

Heck, you could create your own Functor:

function Box(x) {
	return {
		map: (f) => Box(f(x))
	}
}

Box("name")
 .map(name => name.toUpperCase())
 .map(name => "hello, " + name)
 .map(name => name + "!")
 .map(console.log)
// hello, NAME!

and get the same effect as F# proposal, though without special cases for async/await & yield.

So then, why do we even need the pipeline operator?

In F#'s case:

  • it's more accessible (no need to create your own Box)
  • same benefits as the Box - reduces complexity of g(f(x)) into x |> f |> g
  • special cases for async/await & yield would be a great addition
  • combination with the Partial Application proposal improves F#'s pipe and the whole language even further, making it the best option, beating out Hack (proposal 2), and even Hack with the |>> operator (proposal 3), without (!) the need for an extra operator (we'll get to that).

Hack would provide the same benefits as F#, but worse - let's dive in further.

2. The Downfalls of Hack

What the Hack proposal offers is the ability to reference the current variable with a special token (e.g. ^), instead of creating an arrow function:

"name"
 |> ^.toUpperCase()
 |> "hello, " + ^
 |> ^ + "!"
 |> console.log(^)
// hello, NAME!

which maybe looks good first, but not when you consider further:

say we have utility functions:

const toUpper = (x) => x.toUpperCase();
const greet = (x) => "hello, " + x;
const sayLoudly = (x) => x + "!";

with F#, you can just do this:

"name"
 |> toUpper
 |> greet
 |> sayLoudly
 |> console.log
// hello, NAME!

meanwhile with Hack, you'd have to call the function each time:

"name"
 |> toUpper(^)
 |> greet(^)
 |> sayLoudly(^)
 |> console.log(^)
// hello, NAME!

is this a big deal? Yes.

Because you can replicate Hack's behavior with F#, but cannot replicate F#'s with Hack:

// F#, replicating Hack:

"name"
 |> (x) => toUpper(x)
 |> (x) => greet(x)
 |> (x) => sayLoudly(x)
 |> (x) => console.log(x)
// hello, NAME!

whereas you just cannot have function composition with Hack, without calling the functions with the token

// Hack. This will NOT work:

"name"
 |> toUpper
 |> greet
 |> sayLoudly
 |> console.log
// Error

meaning, F# = freedom of choice, and Hack = no freedom of choice (in 2 cases - 1st, composing curried/unary functions concisely, and 2nd - choosing the name of the argument, as opposed to a predefined symbol(s)).

With F#, instead of Hack, an added benefit is that:

  1. you do not need a custom token (e.g. ^) to make it work. it's just a function with an argument!
  2. you can have both behaviors and choose the appropriate one yourself, instead of being forced into using using ^.
  3. you can specify the variable name yourself by creating an arrow function, which is more readable than any token the committee would end up going with anyway.
  4. it is easier to copy-paste/extract code into it's own function, whereas with Hack you cannot do this, since the current value token ^ is only usable in the scope of pipeline operators
  5. combined with the Partial Application proposal, it can be used just like Hack, and even better.

3. The Partial Application proposal

What if your function takes in multiple arguments, but with the F# pipeline operator you can implicitly provide just one argument (otherwise you need to create an arrow function)?

This is what Hack oughts to solve:

const sayLoudly = (x, howLoudly) => x + "!".repeat(howLoudly)

// F#
"name"
 |> toUpper
 |> greet
 |> (x) => sayLoudly(x, 5)
// hello, NAME!!!!!

// Hack
"name"
 |> toUpper(^)
 |> greet(^)
 |> sayLoudly(^, 5)
// hello, NAME!!!!!

great. maybe. the third pipe is supposedly better in Hack than F#, but F# is still better in the first 2 pipes.

But what if? What if F# could get even better?

Here comes Partial (Function) Application, PFA for short:

// same as before:
const sayLoudly = (x, howLoudly) => x + "!".repeat(howLoudly);

// F#
"name"
 |> toUpper
 |> greet
 |> sayLoudly(?, 5)
// hello, NAME!!!!!

// desugars to:
"name"
 |> toUpper
 |> greet
 |> (temp1) => sayLoudly(temp1, 5)
// hello, NAME!!!!!

hooray!

What happened here?

The sayLoudly function got curried / turned into a "unary" function, meaning that the argument x is now the only argument that the function takes in, and all others are inlined.

As you might notice, this looks exactly the same as Hack, just a different token (? instead of ^)

But there's more!

Since this is Partial Application, it is not specific to pipeline operators, as opposed to the Hack's pipeline operator with a token that's only available within the pipeline.

Meaning, you can use partial application anywhere in the language!

Promise.resolve("name")
 .then(toUpper)
 .then(greet)
 .then(sayLoudly(?, 5))

["name"]
 .map(toUpper)
 .map(greet)
 .map(sayLoudly(?, 5))

Box("name")
 .map(toUpper)
 .map(greet)
 .map(sayLoudly(?, 5))

This is the way.

This is exactly what I am advocating for.

And I'm not alone:

4. The JS Community

  • @benlesh, RxJS creator, highly knowledgeable in the FP space:

( https://gist.github.com/tabatkins/1261b108b9e6cdab5ad5df4b8021bcb5#gistcomment-3885521 )

To get what we want with Hack Pipeline without hamstringing the entire JavaScript functional programming community, the ideal solution (that would please everyone) is to land the F# pipeline, and then focus on the partial application proposal.

and further problems with the Hack proposal: ReactiveX/rxjs#6582

Currently our [RxJS] "pipeable operators" rely on higher-order functions. Using these with the Hack Pipeline will be cumbersome, ugly and hard to read: <...>

( #203 (comment) )

A nagging sensation came to me as I looked at the README: pipe() examples are conspicuous by their absence:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x)

pipe(
  x => one(x),
  x => two(x),
  x => three(x),
)(value)

Why are there no example like this? It's a common and unremarkable pattern in FP-JS circles. Even without the exciting point-free potential, surely F# would be synchronous with this? Is there a use-case that Hack solves better that justifies its idiomatic deviation?

This is very similar to my Box Functor example above, and goes hand-in-hand with what @benlesh says, highlighting yet again why F# > Hack.

( https://gist.github.com/tabatkins/1261b108b9e6cdab5ad5df4b8021bcb5#gistcomment-3757152 )

the only downside of F# style is you have to write 3 extra characters per pipe.

Exactly, and as discussed, this is not even an issue, and definitely not enough to justify Hack > F#.

This combined with the cluster that is the discussion about what should be used at the placeholder [token] further pushes me in favor of F# style.

Exactly what got me into this too - the discussion of what special token to use for Hack (I've used ^ here) is a good indicator that the Hack proposal is lacking. People were considering tokens such as ^, %, $_, $$, and other ridiculous ones, because the initial ones (# / $) are incompatible with js -- all of this mess to avoid a simple arrow function with F#. The most upvoted comment there also reaches the same conclusion.

  • @rbuckton, TC39 member, Champion of the Partial Application himself:

( #91 (comment) )

I have been a staunch advocate for F#-style pipes since the beginning,

👀

but it's been difficult to argue against the groundswell in support of Hack-style pipes due to some of the limitations of F#-style pipes.

As I've argued here, I think it's the opposite - it's harder to argue against F#'s pipes rather than Hack's, especially if combined with Partial Application.

If we kept debating on F# vs. Hack, neither proposal would advance, so my position as Co-champion is "tentative agreement" with Hack-style.

Here I strongly disagree. A half-baked solution (Hack, especially without the |>> operator) that will be impossible to revert once it's shipped is FAR worse than no solution at all. And then we have an a far better (AFAIK?) solution: F# + Partial Application, which seems unbelievable that it is not the one that's being considered and advancing into further stages.

Both sides have advantages and disadvantages. F#-style feels to be a better fit for JS to me, especially given the existing ecosystem, and meshes better with data-last/unary producing libraries like Ramda and RxJS. F#+PFA [Partial Application] works well for data-first libraries like underscore/lodash.

I agree, and so does @benlesh & others.

However, F#-style is harder to use with yield, await,

Disagree. See 1. Function composition.

TL;DR: If considered thoroughly - async/await is actually an advantage of F#, not a disadvantage, with a proven track record by the Rust community.

and methods where the topic is the receiver (i.e., ^.method()), which is an advantage for Hack-style.

yes, but this is literally the only advantage of Hack, as discussed above. x => x.method() is just as viable, and works just as well in other cases where Hack is supposedly advantageous. As I already argued above, I think the F#'s way is actually better even in this case - see the last two paragraphs of 2. The Downfalls of Hack, or even better the whole essay. Overall, there's no way the "inconvenience" of doing this the F# way with an arrow function instead of with Hack outweights all the benefits of F# + Partial Application.

So we were stuck at impasse. I don't think a solution that mixes both is tenable, and I'd venture to guess that having multiple pipe operators (one for each) may not make it through committee.

This is regarding the 3rd proposal - Split mix - Hack's |> + F#'s |>>. I agree - it is not very tenable, because we would introduce yet another operator. Furthermore, why all this complexity, multiple operators etc., when we can have the best of both worlds with F# + PFA?

As what feels like the lone dissenter against Hack-style on the committee (or at least, the only vocal dissenter specifically in favor of F#-style), I'd rather not stand in the way of the feature advancing at all, despite my reservations, because I think it's a valuable addition regardless of approach.

Once again - disagree, because a half-baked solution is worse than no solution at all. Digging further, however much time it requires, is definitely worth it, considering how much impact it will have.

This is where I, and a big part of the JS community, needs people from the TC39 committee like @rbuckton, @RyanCavanaugh, @littledan, potentially @DanielRosenwasser & others in favor of the F# approach to stand up (others are just as welcome).

Just look at the interest, discussion, amount of upvotes in the TypeScript's repo for implementing:

a) the F# operator: microsoft/TypeScript#38305 (240+ upvotes, 0 downvotes)

b) the Hack operator: microsoft/TypeScript#43617 (12 upvotes, 8 downvotes)

Clearly, A is preferred over B. Of course, A has been out longer, but the general consensus is pretty clear!

As the implementor of the above 2 PRs, @Pokute, mentions in the @tabatkins discussion:

It's unfortunate that partial application proposal is not mentioned here, since it complements F# pipelines. F# pipelines practically has the burden that partial application as part of F# pipeline proposal is so easily to separate as a distinct proposal that no-one can really object to separating it. Hack-style #-placeholders can not be separated into a separate proposal. Thus I feel like it's unfair to do comparisons where F# pipelines don't additionally have the partial application to help them. It results in a weird situation where the robustness of partial application actually hurts F# pipeline argument.

Initially, the pipeline operator, back from 2018 or whenever, has always been shown first as the F# version, and has only been changed in the README recently to accomodate Hack moving into Stage 2. This is not what the community actually wants! When we talk in general about the Pipeline Operator and how much people want it (e.g. @littledan's slides), most if not all who are excited about it, are still refering to that same version they saw - the F# one.

There are

many @peey,
many @anatoliyarkhipov,
many @nmn,
many @nmn,
many @nmn,
many @samhh,
many @Avaq,
many @OliverJAsh,
many @OliverJAsh,
many @jleider,
many @OliverJAsh,
many @benlesh,
many @tam-carre,
many @benlesh,
many @benlesh,
many @lightmare,
many @kiprasmel,
many @stephaneraynaud,
many @samhh,
many @shuckster,
many @aadamsx, and definitely even more

arguments in favor of F# over Hack, or concerns with Hack, some of which have already mentioned in my writings above.

The few that are in favor of Hack over F#, are far and few in between. Surely, if more people liked Hack over F#, there would be more arguments from their side as well, or at least attempts to answer the F# over Hack arguments thoroughly, which I definitely feel has not been done well enough.

And while I somewhat agree with @tabatkins that:

Counting the comments on a gist is not a representative sample; there's an obvious bias towards comments that are arguing against the OP rather than agreeing with it.

, you simply cannot deny the fact that there indeed is a lot of people in favor of F#, and I am pretty certain it's greater than those of Hack.

Heck, even if the count of people doesn't matter, the arguments for and benefits of F# over Hack are definitely more prevalent and agreed upon than those of Hack over F#, at least in the JS community itself, but apparently not in the TC39 Committee.

5. A metaphor

For me, F# vs Hack is like React vs Angular.

I'm not looking to start a framework/library war, so hear me out:

Why React is great is because it's as close to the javascript language as it can be. Unlike Angular, it didn't try implementing literally every aspect of the language in it's own way with e.g. template rendering and all the mess that comes with it -- for-loops/conditionals as html attributes, 2-way data binding, etc. -- whom will never be better than javascript itself; classes + decorators + dependency injection; specific libraries that only work with Angular; etc. In React, you take existing javascript libraries and just use them, or create your own, who will also be usable elsewhere outside of React (with an exception for Hooks, but that's just fine). It's just javascript, with sprinkles of jsx on top, and unlike Angular, you're not subdued by the authors of the framework/library on what you can or how you can do something, but rather by the language itself, which is a far better bet.

The mistake that Angular made, imo, is trying to re-implement the javascript language itself into an opinionated framework. React just bet on javascript and took off. Try creating a component in Angular - they have a whole CLI tool ready for you just to generate some boilerplate for every new component. React? Lol, create a function, and here is your component. It's just a function. It's simple. It composes well. It's what we love about javascript.

How is this related to F# vs Hack?

Well, there's a lot of parallels!

Hack wants to create an extra token (e.g. ^, or %, or $_, or whatever bogus combination of symbol(s) get picked by a small group of people, effectively enforcing their choice for everyone, instead of letting the individual choose themselves), and that token also won't be usable outside the context of pipeline operators.

F#, on the other hand, works better with curried functions, has an advantage with await being simpler (and has a track record of Rust to back this up, as discussed above in 1. Function composition), and is also just as viable as whatever Hack tries to solve, at the cost of creating an arrow function, which is actually better, because freedom of choice for the name of the argument! Furthermore - it does not introduce an additional token, which makes it easier for the ecosystem to adapt, AND it stays more in-line with the language itself, because, just like in React, it's all about the function. See also 2. The Downfalls of Hack above.

Add Partial Application to F#, and you've got yourself exactly what React got with Hooks. PFA solves F#'s shortcomings and (arguably) beats the only remaining value proposition of Hack. Even better - Partial Application can be used outside F#'s pipeline operators, aka anywhere in the language!

F# is React, Hack is Angular.

Many people, including myself, have coded extensively in both - I can almost guarantee our opinions about the two are identical - React over Angular every. single. time.

6. Is Hack really dead?

I suppose there are limitations with the F# proposal as well? Sadly, I am not too aware of them (other than the things I've mentioned above which are actually not limitations but advantages), but more experienced people could give pointers? (Especially from different FP language backgrounds).

I probably shouldn't be the person writting this essay in the first place - there exist way more experienced people who could've done a better job than me. But I suppose someone is better than no-one.

There are some things I didn't cover, but this is already very long and should serve as a good starter / a continuation from the bikeshedding of Hack's token and the discussion of tradeoffs between F# vs Hack & others.

Thus, I invite everyone to discuss this Pipeline Operator thing further, and in no way rush into Stage 3.

In particular, I'd like at least the following concerns to be addressed, especially by members of the TC39 committee, especially those involved with pipeline operators, whichever side they're favoring (but everyone else is just as welcome too):

  • 1. What you just read here - does it make sense, what are the shortcomings you see, what else could be improved and addressed?

  • 2. Did you read anything new or was literally everything already considered?

  • 3. Do you have any plans to carefully study the usability of the proposals, just like @runarberg highlighted that the Rust community has done? If no - why, if yes - how, and will it have any impact before we reach Stage 3? Or have you already done this and where can we find the results?

  • 4. Do you have any concerns that the current proposal is incomplete / half-baked?

  • 5. Do you have any concerns that if we move forward with the Hack proposal, without including the F#'s |>> operator together at the same time, it could lead to us missing some detail which would prevent |>> (or whatever other token choice for this functionality) from being possible, while the Hack's pipeline operator would already have shipped, and it would be too late to go back?

  • 6. In general and in this case, do you think a half-baked solution is better than no solution at all -- even if there are ways to implement the solution already (Functors etc.), meaning the half-baked solution isn't necessary -- even after reading this and comments/arguments from other threads linked here -- even knowing the impact that it will have -- even knowing that we've made such mistakes in the past (nodejs callbacks vs promises)?

  • 7. Do you know any people in the TC39 Committee who have a strong background in functional programming languages, e.g. Haskell, F#, Elixir etc? Did they participate in the discussions regarding Pipeline Operators?

  • 8. Were there any experts in the FP field invited to participate/help make decisions with regards to the pipeline operator? Did they help? How did the committee handle their feedback, how much weight did it have?

  • 9. Would it be possible in the future to be more transparent of how exactly the decision was made?

  • 10. What members of the TC39 committee made the decision, what members were not present (e.g. @littledan because vacation? etc.)?

  • 11. Do you believe that certain people not being present could sway the decision in one direction or another, why, and how do you make sure both sides are represented fairly and equally?

  • 12. Is it still possible to switch from Hack's to F#'s proposal? Would you help facilitate the switch if you believed that F# is better over Hack, even if the switch could further delay the landing of pipeline operators, because in the long term it would be worth it?

  • 13. Would there be a way to collect all information in one place (or at least clearly link to additional resources from the main source of thruth which probably should be the proposal repo README / RFC issue), as opposed to the current situation with 1) the proposal repo itself, 2) @tabatkins gist (!), 3) discourse, 4) @littledan's slides, etc.

  • 14. What could I or any of the javascript community members do to further help with this? For this proposal, and in future ones?


Thank you. Long live JavaScript.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions