Closed littledan closed 3 years ago
Related to #20.
I suspect whomever wanted to implement this in babel could piggy-back off the F# parsing and just write an additional babel transform.
Right, I see that there were reasons for not going with #20, but it keeps coming back.
Piggy-backing off of F# for the implementation sounds good. I think it'd also be helpful to have an explainer document and maybe a draft specification to document it and make it more concrete. There are some edge cases to think through, e.g., should it be inserted into the argument list if there are parentheses around the thing on the right hand side of the |>.
If anyone wants to take this on, please say so here and I'm happy to help you get started.
I’d be happy to draft an explainer and specification for a tacit-first-argument-only pipe operator later, although for now I’ll be currently working on the Babel transform for the smart-pipeline proposal.
We also will need a name for the new proposal (see #128). I suppose Elixir style might do...
Let's go with Elixir for the name--we've been using it in this repo, and there's a lot of overlap between Elixir and JS programmers, so it could be a useful reference.
There are some edge cases to think through, e.g., should it be inserted into the argument list if there are parentheses around the thing on the right hand side of the |>.
This is going to be the troublesome part, and it's called out specifically in the Elixer docs.
We might also want to consider whether the old bind proposal can be updated to insert the LHS as the first argument instead of the this
value.
Indeed, accommodating both Elixir’s first-argument style in addition to last-argument styles without too much magic is one of the goals of the current smart-pipe proposal—although, of course, that other proposal currently makes other trade offs in exchange for such an advantage.
In regard to “I keep meeting developers who have the intuition that pipeline should be Elixir-style, inserting the argument at the beginning”, it’d be good and helpful to hear from some of those developers or to see some concrete examples of the code they specifically have in mind.
My impression so far is pretty simple, roughly: "this is all solved in [xyz language] and it's not so complex; I don't understand what you're worried about". It would be good to discuss more, though, I agree.
To summarize the biggest downside to Elixir-style: function programming enthusiasts were concerned that they would not be able to use curried functions within a pipeline. For example:
var add = x => y => x + y;
var result = 10 |> add(20);
// Elixir-style would translate the above to:
var result = add(10,20);
// which does not work with the way `add` is written.
Someone proposed (I forget who) a syntactic solution using parenthesis:
var result = 10 |> (add(20)); //=> 30
But many were not happy with it. Personally I think it's a good compromise, and even makes sense: the standard behavior of parethesis is to evaluate the inside before the outside.
If I’m understanding this correctly, then, the Elixir style for which these people have been asking would interpret function/method-call syntax as a special case: x |> f(a, b)
would be f(x, a, b)
, x |> o.m(a, b)
would be o.m(x, a, b)
, and x |> anyOtherTypeOfExpression
would be anyOtherTypeOfExpression(x)
. If I’m understanding this right.
But if I’m understanding it correctly, then that runs into ambiguity problems—the same ones that made me propose restricting smart pipelines’ tacit syntax and forcing everything else to be explicit with the placeholder. For instance, with the Elixir pipe, what would x |> {}.m(a, b)
mean? What would x |> f(a)(b)
mean?
OK, thanks for this summary, I will try to circle back with advocates of this variant and see their thoughts.
As an Elixir developer, it was my assumption that the style was going to be "argument first" (as described by @gilbert and @js-choi above). Per the question above, I think it would look this this, no?
// x |> f(a)(b)
function f(x, a){
function(b) {
...
}
}
Personally, my favorite part of pipelines are their simplicity, and would be fine if "advanced" syntax just wasn't supported as part of the pipeline (you can always put it into one of the functions). In-fact, IMO, I see these advanced syntax situations as almost oxymoron to using pipelines.
Looking forward to the day that I get to write this in JS:
viewer =
request
|> accepts(['json'])
|> verify_header(claims, { realm: 'Bearer' })
|> ensure_authenticated()
|> verify_not_blacklisted()
|> load_resource()
I find the Elixir-style pipe un-JavaScript-y. I can't speak to the intuitions of those who expect that, but I feel like it would be confusing for most JavaScript devs.
I'd also be a little concerned about making the communication around the operator more confusing overall if we add another proposal to the mix. I wouldn't want to add this to the mix unless we saw a strong push for this behavior.
Well, informally, I hear a lot of negativity about smart pipeline, and a lot of people asking for Elixir, but this isn't very scientific.
To clarify, I think we should consider things very open and unsettled at this point, and consider all three options, if we can manage to come up with some kind of initial guess for what to do about the parentheses semantics.
I would like to mention just for historical and informative purposes this is closely related to some past discussion (1, 2, 3, 4, 5) in the bind proposal repo. It's also where this grew out of, and I feel it's very much so worth looking through those to pick apart the foundations of this, so we don't forget what led us here and so we don't lose track of the original intent of the proposal. (I feel we've gotten a little too heavily invested in how it interops with mostly pure functional code, at the cost of how it works in the common case of simple pipelining and how it works with procedural code.)
I agree with @mAAdhaTTah that built in currying is unintuitive for a native javascript operator. Are there any other cases of javascript supporting currying natively?
A possiblity that combines Elixer-style first-arg passing with the syntactic structure of the bind proposal (as suggested above by @isiahmeadows).
Syntax:
CallExpression:
CoverCallExpressionAndAsyncArrowHead `->` MemberExpression Arguments
CallExpression `->` MemberExpression Arguments
Common usage scenario:
import { map, filter, collect } from 'iter-funs';
someIterator->map(cb1)->filter(cb2)->collect().forEach(cb3);
Pros:
->
and .
Cons:
->
might be surprising for those with C++ experience.Thoughts?
Let me clarify: first-arg style is well supported by the language outside of any pipeline feature (be it syntax or function). If I create a library I can just choose that style and I know it will work well regardless of whether my users are using pipeline-things or not.
Current RxJS, on the other hand, requires use of a "pipe" method (or pipeline operator) to make things ergonomic.
@zenparsing
- Usage of
->
might be surprising for those with C++ experience.
I wouldn't consider this a significant con considering how few JS devs use C++. You could make a better argument for PHP, since their ->
is effectively our .
.
I would posit the second con, about native support for point-free support, is probably more significant, but I'm not convinced it really carries the benefits the FP community say it does in JS. JS FP works more like Elixir and Clojure than OCaml or F# - arity is a concern, and it's impractical to define any generic auto-currying mechanism because callees can have multiple arities.
As for what primitives they have for flipping function application:
x & f a b
↔ (f a b) x
x |> f a b
↔ (f a b) x
and some use a userland function composition operator, usually defined as f @ g
↔ let (@) f g = fun x -> f (g x)
x |> f a b
↔ (f a b) x
almost exclusivelyx |> f(a, b)
↔ f(x, a, b)
(-> x (f a b))
↔ (f a b x)
, (->> x (f a b))
↔ (f x a b)
I did note when I first proposed it that one of the other major pros (one you didn't list) is that it's already a very prevalent idiom. Lodash, Underscore, and jQuery's array/object utilities all use the first argument for the data itself, so users could start using it right away and we wouldn't need to tell everyone to change all their idioms.
And another pro with a->b(...xs)
↔ b(a, ...xs)
: a->await b(...xs)
is pretty obviously not ambiguous, and it obviously evaluates to await b(a, ...xs)
. It dodges the whole issue of how to handle async
and yield
by just not letting the RHS be just any expression.
Also, just wanted to mention to others here that @zenparsing's idea was something I first suggested here a while back, coming from the context of iterables and generators.
For more context, ->
has been previously discussed for a short function literal that doesn't bind this
, but I am not convinced we should add a feature for that (given how confusing this
is already).
I'm not sure the property access confusion concern is C++-specific, as @zenparsing has specifically advocated a pattern like this for private state.
@isiahmeadows makes a good point about curried vs uncurried languages. I agree that arg-first is already idomatic JavaScript.
To add another case study, ReasonML has chosen ->
for their pipeline function. This is especially interesting considering the operator syntax was originally |>
.
didn't quite understand why -> works and |> don't.
״We also cannot use the |> operator here, since the object comes first in the binding. But -> works!״
@mrsufgi Reason actually has both F#-style and Elixir-style forms, but the docs omit this bit:
|>
is inherited from OCaml's standard library prelude (their Pervasives
module), and x |> f(g)
is equivalent to f(g)(x)
/f(g, x)
. The corresponding OCaml function is this global: let (|>) x f = f x
.->
is a newer alternative where x -> f(g)
directly desugars to f(x, g)
. It's expanded like a compiler macro, rather than exposed directly as a function. Bucklescript's |.
, equivalent to Reason's ->
, is also treated similarly.You'll see a lot of older code using the first, since the second is relatively new.
But it's especially notable that Reason specifically added x->f(g)
↔ f(x, g)
for both JS and native targets, despite x |> f(g)
↔ f(g, x)
already existing as a viable, working alternative.
@js-choi To be honest, I'm not terribly keen on a "Split Mix"-style operator, as I find similar operators with different semantics too complicated to be worthwhile. I'm not opposed to doing Elixir-style pipelining with a different operator (e.g. ->
), although I don't know how the committee would feel about 2 (or potentially 3, if we include bind) operators for various forms of pipelining.
I'm skeptical of having multiple pipeline-related operators. We should have a pretty good understanding of why we really need both. Less to learn is better, all else being equal. But interesting to hear that other languages went that way.
I'm concerned introducing or using an Elixir
-style operator would mean TC39 blessing first-arg semantics which would have repercussions for existing userland libraries like Ramda that use last-arg semantics. That was one of the motivators for partial application, in that you could place ?
in either first-arg or last-arg positions.
It's a good point @rbuckton. Favoring a certain style is unavoidable unless we go with something like hack-style/smart-mix or couple pipelining with partial application.
We do have to consider that coupling proposals multiplies risk, though.
For future readers: If @mAAdhaTTah’s https://github.com/tc39/proposal-pipeline-operator/issues/143#issuecomment-458715646 and @littledan’s https://github.com/tc39/proposal-pipeline-operator/issues/143#issuecomment-458769275 are confusing, it’s because I deleted a comment in which I mused about someone reviving the old “split-mix” system in a formal proposal, in which different pipe styles (Elixir, F#, Hack) are used by different operators, which would favor no particular style. I deleted it because I wanted to think about it more before anyone saw it but it looks like it was too late, heh. It seems to probably be a nonstarter anyway.
I do think that not favoring a particular system is an important goal. It’s a big part of why I’m enthusiastic about any proposal that can comfortably accommodate multiple/mixed styles (whether “smart” or “split” or whatever). I think there are still unexplored ways of reconciling different mixed styles, including mixed styles supporting Elixir style.
I maintain Reason, mentioned by @gilbert. Someone asked me to justify it here, so here I am.
There are very nuanced but important distinctions to have pipe first rather than last, that warranted going the extra length of adding it rather than using the existing |>
.
The more important issue is outlined e.g. here. Type inference, and general editor tooling, don't work well at all with last-arg, akin to the issue of import foo from bar
rather than from bar import foo
: you need some epic analysis to automplete foo
in the former case. The truth is that all the tools are top-down left-right biased. For example, map(a => addInts(a, b), myStringArray)
will give incorrect hint (from the compiler, editor tooling including autocompletion and type hint, refactoring, etc) that myStringArray
has the wrong type. It's usually the case that the function is the wrong type (data over functions, functions over macros, etc). map(myStringArray, a => addInts(a, b))
will correctly warn at the more local addInt
part.
FP literature kinda traditionally ignored these ergonomic problems, because "as long as the compiler errors it's theoretically fine". For a real-world, user-friendly production language, the quality of the compiler's help matter a lot. You start using different designs at that point.
To generalize this, if we check the FP languages themselves, especially those with currying, we'd realize that arg-last isn't well-defined (in the most serious sense). Consider:
int => float => string => int
It looks like string
is the last argument (last int is the return type). But look:
type t = string => int
type myFunc : int => float => t
Now it looks like float
is the last argument! It's not just a matter of looks. These languages usually can't tell the difference. Consider if t
was polymorphic and you couldn't just inline the types or something. So, in a curried language, you can only know which one is the first argument, but can not tell which one is the last argument. The wrecking of editor tooling earlier is one of the many manifestations of this.
The history of arg-last in JS libraries are to emulate actual FP languages' semantics. In turn, the semantics of arg-last in those languages are usually undefined in the context of arg-last pipe. Imo it'll be legacy decision in the making, to go with arg-last pipe for JS.
Lastly, the awkward and unspoken argument for arg-last comes from the fact that some find neat little elegance in the pipe in conjunction with automatic partial application. That's not a thing in JS (clarification: currying can be, but we're taking about auto partial application, when you call the function). And if we're even on the topic "neat parallels", OO methods being just a regular function that takes an object as first argument, seems like a more "elegant" analogy for FPs to make, rather than some other neat-parallel-with-currying-but-not-really-that-elegant analogies anyway.
Honestly, take it from the creator of TypeScript from the link I provided above. That person seems to know what he's doing =).
@chenglou thanks for the thorough response. I have always wondered why Bucklescript supported both operators.
I'd like to bump the original request of adding the Elixir-style semantics of x |> f()
↔ f(x)
+ x |> f(...)
↔ f(x, ...)
to the list of considered alternatives, just to match reality. I've seen it talked about in the notes as well as extensively in the issues, but the README and wiki don't seem to match this reality.
I feel Elixir-style pipeline is like the compromise of OO world and FP world, though both world may accept it, both world may also not satisfy with it...
The elixir pipeline has a very interesting feature in that you don't have to curry your non-unary functions for them to work with it.
Indeed it can help replace the current pervasive "chaining" strategies. Currying is cool, but it's not widely used in JavaScript. The "object" of the function being the first argument is.
The current proposals which I've been able to find (unary functions only, if you need more arguments you need an inline arrow function or to curry your function) will only cause more boilerplate and confusion. Does this package curry its functions? Did I curry this function? Let me curry this function so people can use pipelines with it.
The simple syntax x |> foo()
being equivalent to foo(x)
has a tiny learning curve, doesn't affect anything negatively and has the potential to be a great boon to code reuse.
Edit: forgot to mention that clojure's similar ->
macro works the same way. Check it out: https://clojure.org/guides/threading_macros
So two very functional languages don't force you to curry or change the signature that makes the most sense for regular usage when you just want to pipe several functions. What then is the reasoning for this requirement in this proposal?
I've read someone concerned with performance. Since this proposal is syntax sugar, your minifier will take care of desugaring it, such that it has less characters and parses faster. Terser will support this for sure, that's a Promise I can certainly keep.
@fabiosantoscode The performance concerns are typically about the overhead of creating curried functions, not size.
Got it! Yes, that makes sense. Currying is not free.
Lemme clarify that for our pipe-first implementation, we just do a syntax transform back to the normal function call. Doesn't disrupt any existing semantics, extremely predictable and toolable, and no runtime performance hit, obviously.
First argument topic
is how I envision Javascript would be in the FP world. It has worked very well for Elixir. It gels well with how function arguments are handled in JS, including variadic arguments. It's extremely consistent with how vanilla JS functions work. It is also performant compared to alternatives.
Using String.split()
as an example: the functional equivalent of String.split([separator, [, limit]])
would be split(topicStr, [separator, [, limit]])
. It's very intuitive, and does not require a function that returns another function or such. Same with String.substr()
, String.indexOf()
. But the same would apply to other libraries in general, like base64encode()
, JSON.parse()
, sha256hash()
etc.
Let's say I want to write a Date library, would I want my function to be of the form addDays(date, numDays) => newDate
, or would I want it to be of the form addDays(numDays)(date) -> newDate
? I feel first argument topic
is a more natural way of doing FP in Javascript.
First-argument injection is, I feel, the least good of all the options presented so far.
val |> (x=>foo(1, x))()
? Maybe you can omit the final parens if there's a rule that when the top-level expression isn't a function call it acts like F#-style and implicitly calls it with the topic as the sole argument, allowing val |> x=>foo(1,x)
? (Assuming, as with F#-style, that we satisfactorily solve the parsing issues with omitting wrapping parens around arrow funcs in pipe bodies.)@tabatkins thank you for your comment. Those are all valid concerns. I wrote down some of my thoughts regarding your comment, but decided to hold it off until I come up with something a little more concrete.
First-argument injection is, I feel, the least good of all the options presented so far.
- Your code doesn't read like it was defined - all the arguments in a pipeline call are in the wrong position relative to calling the function literally.
I agree. My reply is this is probably a case where the concern is not born out in practice. Clojure, Elixir, Racket all implement argument injection as their pipe mechanism, and users of those languages do not find it confusing.
- It's an entirely novel calling syntax for JS. Both F#-style and Hack-style leverage existing JS syntax/calling idioms. (This isn't necessarily a strike against, but it means there has to be a very good reason why introducing such a novel syntax is worthwhile, vs just reusing existing familiar syntaxes.)
I presume you meant it is a novel mechanism (instead of syntax)? I would say this mechanism is very easy to get used to, as born out in practice in all languages that implement it.
- Most importantly imo, if your function is not written in a way such that you want to pass the topic into the first argument, adjusting your pipeline body to account for that is very non-obvious and strange. As far as I can tell, you have to write something like
val |> (x=>foo(1, x))()
? Maybe you can omit the final parens if there's a rule that when the top-level expression isn't a function call it acts like F#-style and implicitly calls it with the topic as the sole argument, allowingval |> x=>foo(1,x)
? (Assuming, as with F#-style, that we satisfactorily solve the parsing issues with omitting wrapping parens around arrow funcs in pipe bodies.)
Yes, I agree. That's where Hack proposal would be useful :) My main gist is that argument injection is not a novel idea, and works well in the languages that already implement it. These languages typically pick argument injection as their default pipe mechanism, and argument replacement as the fallback. We shouldn't reject it out right, and it may have some synergy with the Hack proposal.
I decided to write a new proposal that combines both argument replacement and argument injection to illustrate my points. Maybe it can alleviate some of the concerns: https://github.com/tc39/proposal-pipeline-operator/issues/84#issuecomment-831328201
* Your code doesn't read like it was defined - all the arguments in a pipeline call are in the wrong position relative to calling the function literally.
I'm going to agree with @highmountaintea that this doesn't tend to be an issue when the syntax is clear and unambiguous. This function—argument order inversion can even be seen in non-pipe contexts, for example method calls in Python:
class K: # definition order is: funcName, subject(self), *args(x, y) def funcName(self, x, y): pass
obj = K()
obj.funcName(x, y)
And it doesn't appear to cause trouble once you get used to it.
> * It's an entirely novel calling syntax for JS.
For the language, yes, but there is precedent in libraries. For example Lodash:
```js
// the basic form of lodash functions operate on the first argument:
_.mapValues( _.groupBy(bills, 'type'), g => _.sumBy(g, 'amount') )
// but you can also wrap your input in a proxy and pipe it like this (and since we're talking
// about syntax, let's ignore the semantic difference that this evaluates lazily; what's notable
// is that these methods are not documented individually, they simply defer to the documentation
// for the functions operating on their first argument):
_(bills)
.groupBy('type')
.mapValues(g => _(g).sumBy('amount'))
.value()
// which is very similar to how you would use the regular no-proxy functions with Elixir-style pipeline:
bills
|> _.groupBy('type')
|> _.mapValues(g => g |> _.sumBy('amount'))
First-argument injection is, I feel, the least good of all the options presented so far.
To me it feels like the most balanced of all the alternatives presented.
I like the minimal F# proposal for its simplicity and conciseness. It is perfect for piping through unary functions. To pass more than just the single argument you need to wrap the call in an arrow function. A bit clumsy, but still fine. However, it gets messy with await/yield. Even the impressively well thought out proposal for await/yield in F#-style makes it obvious that with this requirement, it's no longer just another operator you plug into the grammar; you need to bend the rules around it.
I like the Hack-style proposal for its consistency and flexibility. It doesn't need any special parsing rules, and it always looks the same, whether you're passing 1 or many arguments. However it does look awkward with unary functions. And I absolutely hate the idea of denoting the topic variable with punctuation, as if JS wasn't already littered with it.
Now why do I think the Elixir-style strikes a good balance between the two? Because F#-style is only really good with unary functions. Elixir-style is good whenever you want to pipe the first argument, i.e. unary functions and then some, and the price you pay over F# for that wider range of easily usable functions is mere ()
.
With unary functions - F# wins:
input |> func // F#
input |> func() // Elixir
input |> func(#) // Hack
func(input) // desugar
Binary with topic first - Elixir wins:
input |> (x => func(x, 2)) // F#
input |> func(2) // Elixir
input |> func(#, 2) // Hack
func(input, 2) // desugar
Binary with topic second - Hack wins:
input |> (x => func(1, x)) // F#
input |> (x => func(1, x)) // Elixir **1 (implicit arrow-function call)
input |> (x => func(1, x))() // Elixir (explicit call)
input |> func(1, #) // Hack
func(1, input) // desugar
**1
this is the extension proposed earlier. Elixir-style requires a call-expression on the right-hand-side. If there's an arrow-function-expression instead, it's safe to assume you want it called. Of course you can add explicit ()
, but they're redundant, the function will be called either way. As for removing the parentheses surrounding the arrow function, that's a can of worms affecting any style that allows it, so no point lifting the lid here.
Mid-pipeline await:
input |> asyfunc |> await |> regfunc // F#
input |> await asyfunc() |> regfunc() // Elixir **2
input |> await asyfunc(#) |> regfunc(#) // Hack
regfunc(await asyfunc(input)) // desugar
**2
this is another extension. Again, Elixir-style requires a call-expression on the right-hand-side. If there's an await-prefixed call instead, you want to await the result (similarly with yield).
Note that unlike special parsing rules needed for await in F#-style, in Hack-style |>
is a regular binary operator, and in Elixir-style it's almost like a regular binary operator, only with right-hand-side restricted to certain expression types (call or arrow, optionally prefixed with await or yield; i.e. existing productions, no new restrictions on specific token sequences).
For completeness sake, this is what minimal pipelines + partial application proposal + some helpers would look like:
input |> func
input |> func(?, 2)
input |> func(1, ?)
const Promise = pipeable(globalThis.Promise) // https://gist.github.com/ducaale/93bd6d49314ef1383f50be95edca9d6e
await (input |> asyfunc |> Promise.then(regfunc) |> Promise.then(regfunc2))
For completeness sake, this is what minimal pipelines + partial application proposal + some helpers would look like:
input |> func input |> func(?, 2) input |> func(1, ?)
That was part of the initial rationale for partial application, to allow both leading-arg (lodash style) and trailing-arg (Ramda style) usages (along with its other capabilities).
For me, both F#-style (w/partial application) and Hack-style feel like JavaScript, with semantics that fit with the language. Elixir-style doesn't quite fit with JavaScript semantics, and I'm concerned it would introduce a footgun that is the polar opposite of the confusion around this
(where instead of a hidden parameter, we now have a hidden argument).
I wrote a draft patch for the Babel plugin. You can try it in this experimental REPL (requires manually setting Presets -> Pipeline proposal -> Elixir)
Another Elixir-style pipeline and capture operator playground I've been experimenting with: engine262 fork
Closing, since the proposal has advanced with "Hack-style" pipeline syntax.
I know we previously discussed this at length and settled on F#-style, but I keep meeting developers who have the intuition that pipeline should be Elixir-style, inserting the argument at the beginning. Would anyone be interested in writing this up formally and implementing it in Babel, so we could get hands-on experience with this alternative?