Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Hack-style Pipelining #84

Closed
zenparsing opened this issue Jan 22, 2018 · 154 comments
Closed

Proposal: Hack-style Pipelining #84

zenparsing opened this issue Jan 22, 2018 · 154 comments

Comments

@zenparsing
Copy link
Member

zenparsing commented Jan 22, 2018

The current proposal (in which the RHS is implicitly called with the result of the LHS) does not easily support the following features:

  • Piping into a function with multiple arguments
  • Calling methods on a piped value
  • Awaiting a piped value

In order to better support these features the current proposal introduces special-case syntax and requires the profilgate use of single-argument arrow functions within the pipe.

This proposal modifies the semantics of the pipeline operator so that the RHS is not implicitly called. Instead, a constant lexical binding is created for the LHS and then supplied to the RHS. This is similar to the semantics of Hack's pipe operator.

Runtime Semantics

PipelineExpression : PipelineExpression |> LogicalORExpression
  1. Let left be the result of evaluating PipelineExpression.
  2. Let leftValue be ? GetValue(left).
  3. Let oldEnv be the running execution context's LexicalEnvironment.
  4. Let pipeEnv be NewDeclarativeEnvironment(oldEnv).
  5. Let pipeEnvRec be pipeEnv's EnvironmentRecord.
  6. Perform ! pipeEnvRec.CreateImmutableBinding("$", true).
  7. Perform ! pipeEnvRec.InitializeBinding("$", leftValue);
  8. Set the running execution context's LexicalEnvironment to pipeEnv.
  9. Let right be the result of evaluating LogicalORExpression.
  10. Set the running execution context's LexicalEnvironment to oldEnv.
  11. Return right.

Example

anArray
  |> pickEveryN($, 2)
  |> $.filter(...)
  |> shuffle($)
  |> $.map(...)
  |> Promise.all($)
  |> await $
  |> $.forEach(console.log);

Advantages

  • All use cases described above are supported with no special-case syntax.
  • By removing the implicit-call behavior, we remove a potential source of confusion for developers.

Disadvantages

  • By requiring an explicit function call, functional programming patterns that rely heavily on currying will be less ergonomic.

Notes

The choice of "$" for the lexical binding name is somewhat arbitrary: it could be any identifier. It should probably be one character and should ideally stand out from other variable names. For these reasons, "$" seems ideal. However, this might result in a conflict for users that want to combine both jQuery and the pipeline operator. Personally, I think it would be a good idea to discourage usage of "$" and "_" as variable names with global meanings. We have modules; we don't need jQuery to be "$" anymore!

@littledan
Copy link
Member

Thanks for writing this out in such a detailed way. I didn't understand exactly what you were suggesting before. I like how this direction is very explicit and general; it completely handles nested subexpressions in a way that I didn't previously understand was possible.

The cost seems to be the loss of terse syntax for x |> f |> g, which becomes x |> f($) |> g($). I'm not sure how important conciseness for that case is.

Bikeshedding: I'm not sure if we want to use _ or $ when they are so widely used for libraries. There are lots of other names possible, or we could go with something currently untypable like <>.

@zenparsing
Copy link
Member Author

zenparsing commented Jan 22, 2018

I'm not sure if we want to use _ or $ when they are so widely used for libraries.

True, using $ or _ will probably make some people unhappy. On the other hand, we should prioritize the future over the past.

Another thought: a pipe operator with these semantics would obviate the need for the current :: proposal. We could re-purpose binary :: for just method extraction:

class XElem extends HTMLElement {
  _onClick() {
    // ...
  }

  connectedCallback() {
    this.addEventListener('click', this::_onClick);
  }

  disconnectedCallback() {
    // Assuming this::_onClick === this::_onClick
    this.removeEventListener('click', this::_onClick);
  }
}

which would be pretty nice and intuitive.

@gilbert
Copy link
Collaborator

gilbert commented Jan 22, 2018

Nicely done! The loss of terse syntax for unary and curried functions is arguably significant, but in return you get pretty much everything else one could ask for 😄

Some more fun features of this syntax:

let lastItem =
  getArray() |> $[$.length-1]

let neighbors =
  array.indexOf('abc') |> array.slice($ - 1, $ + 2)

let silly =
  f |> $(x, y)

@dead-claudia
Copy link
Contributor

This looks a lot like a variant of the partial application proposal, just using a $ (a valid identifier) instead of a ? (which is what that proposal uses).

Maybe we could take some of the features proposed here and integrate them into that proposal, but from the surface, this looks flat out identical to that proposal mod the change in syntactic marker.

@mAAdhaTTah
Copy link
Collaborator

I guess I'm surprised $ would even be possible. Something like the $.map example would be especially confusing because jQuery has a $.map. Could the grammar specify whether that's supposed to call immediately or not? If someone did this:

const $ = require('ramda') // using ramda cuz I'm familiar

[1, 2] |> $.map(x => x + 1)

it can't tell when it should use the return value of Ramda's curried map or the Array.prototype.map. Is using a valid variable name explicitly intended?

@isiahmeadows My understanding is this proposal explicitly evolved out of prior attempts to combine the two proposals.

Given that, though is the intention to actually combine the proposals or use the placeholder semantics defined as part of the pipeline proposal as a springboard for adding partial application to the language generally?

@js-choi
Copy link
Collaborator

js-choi commented Jan 22, 2018

I'm not actually proposing this, but, for what it's worth, the Clojure programming language has a similar pipelining feature called the as-> macro. It has a somewhat different approach, though: it requires explicitly specifying the placeholder argument at the beginning of the pipeline—which may be any valid identifier.

That is, the example in the original post would become something like:

anArray |> ($) { // The $ may be any arbitrary identifier
  pickEveryN($, 2),
  $.filter(...),
  shuffle($),
  $.map(...),
  Promise.all($),
  await $,
  $.forEach(console.log)
}

…but the $ could instead be _ or x or or any other valid identifier. That's the advantage: It doesn't implicitly clobber a specially privileged identifier; it requires an explicit identifier for its placeholder argument binding. The downside is that it needs to have its own block—which would contain a list of expressions through which to pipe placeholder data, and within which the placeholder identifier would resolve—rather than the current |> binary operator. That's probably not tenable, I'm guessing.

Having said all that, like @isiahmeadows I too am wondering if ?, as a nonidentifier, would not be a better choice here. The relationship between this proposal and the partial-application proposal may need to be once again considered.

@JAForbes
Copy link

I see the benefit of this for multi arg functions, but I don't understand why we need a placeholder for single arg functions? A lot of the FP community in JS has moved or is moving to unary only functions, and so for us it'd be quite natural to do x |> f |> g |> h, littering that expression with $ (or some other symbol) for every application would be pretty noisy, so I'd like to know the benefit.

@JAForbes
Copy link

JAForbes commented Jan 23, 2018

I'm also really surprised await is even a part of this proposal.

anArray
  |> pickEveryN($, 2)
  |> $.filter(...)
  |> shuffle($)
  |> $.map(...)
  |> Promise.all($)
  |> await $
  |> $.forEach(console.log);

Should just be with lodash/fp, ramda, sanctuary ( etc )

anArray
  |> pickEveryN(2)
  |> filter(f)
  |> shuffle
  |> map(f)
  |> Promise.all
  |> then( forEach(console.log) )

|> is a very simple operator, it just applies a function with the result of the previous value, it doesn't need any other semantics.

If we want to use legacy OO style libraries with this syntax, we should have a separate proposal (like the placeholder proposal), and it should not be pipeline specific, it should be language wide. The worst case scenario with arrow functions is completely fine. And the fact it's not as convenient as unary functions is a good thing, it's a subtle encouragement to think compositionally and design API's that enable that.

E.g. let's say we're using pipeline with no placeholder, with no await, with standard lodash and bluebird.

anArray
  |> x => pickEveryN(x, 2)
  |> x => filter(x, f)
  |> shuffle
  |> x => map(x, f)
  |> Promise.all
  |> then( xs => xs.forEach(console.log) )

It's not bad for an API that is not designed for unary application.

I think we may be over-complicating things to support functionality we don't need to support. The work-around of using arrow functions is still simple to follow, and any special placeholder syntax should be language wide anyway and probably beyond the scope of this proposal.

@mAAdhaTTah
Copy link
Collaborator

mAAdhaTTah commented Jan 23, 2018

Unfortunately, latest guidance from TC39 is they want await support. This is a syntax that will need to be solved before it can advance.

@TehShrike
Copy link
Collaborator

Is nobody at TC39 willing to oppose await in this proposal? It seems like a bad idea to take this super-handy operator common to other languages and bolt on this awkward feature.

@TehShrike
Copy link
Collaborator

To be fair, I haven't heard the discussion, so maybe I'm missing something?

@TehShrike
Copy link
Collaborator

The notes from the discussion seem more negative towards the idea of bolting on await/yield than anything http://tc39.github.io/tc39-notes/2017-11_nov-29.html#interaction-of-await-and-pipeline

@JAForbes
Copy link

JAForbes commented Jan 23, 2018

Yeah I agree, and the arguments for await are either not justifications or they are reaching for exotic scenarios.

KCL: There are valid usecases for await expressions in a pipeline, an async function that returns a function could be used here.

This is supported by the above |> then( f => f(x) ). Also if exotic examples are required to justify this syntax, then it's not really justifiable.

DE: Yes, I think it'd be very strange if we didn't allow this.

I think we need more guidance than this.

Its a very simple operator, we don't need to complicate it. I think @zenparsing's suggestion is an elegant solution for the constraints tc39 have provided, but I don't think the constraints have any merit and we should seek further guidance.

If we ship it with await banned, and then people want to add await later if/when there's a legitimate justification, we can. It won't break any code. But I personally doubt there will be a justification, await solves a different set of problems.

I'd be in favour of some static functions on the Promise object (e.g. Promise.then ) and ban await for now with an option to add it later if there's ever a need.

@bakkot
Copy link

bakkot commented Jan 23, 2018

I don't think this is the right thread to revisit await. As a committee member, though, I agree with the assessment that it's something this proposal needs to handle if it's to get through; if you want to discuss that further, I think a new issue would be appropriate.

@mAAdhaTTah
Copy link
Collaborator

mAAdhaTTah commented Jan 23, 2018

Personally, I think part of what makes the pipeline operator so elegant is its simplicity. I'd be extremely happy simply writing code like this:

anArray
  |> x => pickEveryN(x, 2)
  |> x => filter(x, f)
  |> shuffle
  |> x => map(x, f)
  |> Promise.all
  |> then(xs => xs.forEach(console.log))

This builds on syntax I'm already familiar and comfortable with (arrow functions), themselves already beautifully simple and elegant, and it feels like a natural evolution for them. Introducing both the |> and a pipeline-only placeholder syntax feels like... too much. I don't think the extra syntax is needed, and I think we'd be better off treating that as a different issue.

I still think I prefer inlining await into the pipeline, despite ASI hazards, but in the interest in exhausting all options, what about maintaining the elegance of the current approach, but use the placeholder syntax to "solve" await:

anArray
  |> x => pickEveryN(x, 2)
  |> x => filter(x, f)
  |> shuffle
  |> x => map(x, f)
  |> Promise.all
  |> await ?
  |> xs => xs.forEach(console.log)

such that the placeholder is only used in await in a pipeline? For the most common case (sync pipelining), we still get a great syntax with arrow functions, and still looks pretty clean in the async case, and perhaps could be useful as a "foot in the door" for placeholders without requiring fully implementing that proposal, which would give developers an opportunity to start developing an intuition for it.

Thoughts?

@zenparsing
Copy link
Member Author

zenparsing commented Jan 23, 2018

@JAForbes Javascript is a big, huge tent, filled with many people that don't consider OOP "legacy". We need to consider everyone under that tent when designing syntax.

@mAAdhaTTah I don't think we can assume yet that "arrow functions without parens" syntax is going to work out: see #70 (comment). If we have to rewrite your example with the parentheses, it doesn't look as attractive:

anArray
  |> (x => pickEveryN(x, 2))
  |> (x => filter(x, f))
  |> shuffle
  |> (x => map(x, f))
  |> Promise.all
  |> await
  |> (xs => xs.forEach(console.log))

@JAForbes
Copy link

JAForbes commented Jan 23, 2018

@zenparsing

Javascript is a big, huge tent, filled with many people that don't consider OOP "legacy". We need to consider everyone under that tent when designing syntax.

Let me clarify what I mean by legacy. I don't mean legacy today, I mean legacy after this feature lands and is adopted by the community.

This feature will be so transformative for the community, it's hard to over estimate. Currently if you want a library to support some functionality you need to open a pull request and add a method to their prototype. This feature allows us to do all of that in user land by just piping their library into our own functions.

Inevitably that will happen.

That is going to affect the status quo, and we should design for that reality not for what is currently normal.

If we have to rewrite your example with the parentheses, it doesn't look as attractive:

I think it still looks fine, and it keeps this language feature simple. Let's keep the semantics simple and ship it.

@zenparsing
Copy link
Member Author

@JAForbes Can you provide an example, using the semantics of this proposal, that shows how it is unergonomic for some use cases?

@bakkot
Copy link

bakkot commented Jan 23, 2018

I don't mean legacy today, I mean legacy after this feature lands and is adopted by the community.

I really don't think this feature will drive OO programing out of JavaScript.

Currently if you want a library to support some functionality you need to open a pull request and add a method to their prototype.

See the interfaces proposal for a different approach to this problem.

@JAForbes
Copy link

JAForbes commented Jan 23, 2018

@bakkot I didn't say it would drive OO programming out of JS. I'm saying the release of this operator will change ecosystem norms for data transforms. You can still have classes, and OO and all that stuff, but for data transformation |> will have a massive affect on the community. Designing for an ecosystem that doesn't have |> yet is a mistake. Libraries will change, assumptions will change, patterns will be built entirely around this feature because it's that much of a big deal.

E.g. waiting for Array utilities to land becomes less of a concern, we can do that in userland. Or waiting for Promise, Observable, Iterator functions etc. We can add features immediately and get the same convenience we are used to with chaining. It relieves pressure on library authors, on language designers, it gives more power to the community. We're going to see the pipeline being used in all kind of innovative contexts e.g. decorating hyperscript and jsx. It's hard to predict beyond that, but this feature is a huge deal and it will have drastic affects on the JS ecosystem.

So let's design for that. Just supporting the core functionality will bring a lot of value with very little risk. I'm not at all saying OO will become legacy. I get why you are reading what I'm saying that way, and I apologise sincerely for being unclear but that is not what I am trying to communicate at all. I just want to make sure we don't bake in things that make the feature confusing for an imaginary use case when a simpler proposal provides so much value and is so much easier to teach and adopt.

Designing languages is like designing infrastructure for a city. You need to plan for the future. Future economies, future culture, future populations, future needs. I'm saying, let's not bind ourselves to our assumptions about usage based on the present day, let's keep our options open and add affordances for await and methods if the community needs it. In the mean time, arrow functions work just fine and there's a whole suite of libraries that are made to work perfectly with this feature. Let's ship the simplest possible operator, and give ourselves room to extend if we need it.

@JAForbes
Copy link

@zenparsing

Can you provide an example, using the semantics of this proposal, that shows how it is unergonomic for some use cases?

I think your proposal is a brilliant design for the constraints that have been presented. It really is elegant. But I want to ensure that there is a legitimate need for a compromise.

The common case for pipeline execution is composing sync functions. This proposal solves for a lot of contexts but sacrifices the absolute simplicity of x |> f |> g |> h, and instead it becomes x |> f($) |> g($) |> h($). It's not at all unreadable, or unergonomic. But its crossing a threshold of simplicity that I would want to avoid unless it's absolutely necessary. It makes it that much scarier for beginners, that much more intimidating.

If it turns out in #86 there are some solid justifications, I'd support your proposal because I think it's probably the best compromise that could be concocted. But I don't want to compromise without justification. And I'd really like us to ship the simplest possible operator.

@gilbert
Copy link
Collaborator

gilbert commented Jan 23, 2018

But its crossing a threshold of simplicity that I would want to avoid unless it's absolutely necessary.

I think you need to define what you mean here. "Simple" is often treated as an always-good attribute instead of the tradeoff that it truly is. Aren't function parameters simple? Then why complicated them with default values? Aren't variable assignments simple? Then why complicate them with destructuring? Aren't script tags simple? Then why complicate things with modules?

Reconsidering the meaning of the word, both the original and this Hack-style proposal are equally simple. They both have a simple rule: "call the right-hand side" and "create a binding for the right-hand side", respectively. It just so happens that Hack-style is more ergonomic for a much broader variety of use cases, but at the cost of being more verbose for the simplest use case (unary function calls) Edit: and also curried functions, to be fair.

@gilbert
Copy link
Collaborator

gilbert commented Jan 23, 2018

Going back to pointing out issues with this proposal, what happens when the temporary identifier is not used on the right hand side? Example:

var result = x |> f($) |> g

Should this result in a syntax error, or would result now point to the function g? Or even crazier, would the parser detect this and invoke g like in the original proposal?

I think this is important to address since I imagine some will forget to pass $ into a righthand expression.

@bakkot
Copy link

bakkot commented Jan 23, 2018

I agree with the concerns about the choice of binding.

That aside, I want to point out another nice synthesis (with a proposed feature): this works nicely with do expressions. For example:

anArray
 |> pickEveryN($, 2)
 |> do { let [fst, snd, ...rest] = $; [snd, fst, ...rest]; } 
 |> $.map(whatever)

Maybe that do would be better extracted to a function, but I think that's true of most uses of do expressions, so whatever.

@JAForbes
Copy link

@gilbert

I think you need to define what you mean here.

Yeah good point. I think this proposal is simple in the abstract. It's design is simple. But it's design also inherits the complexity of the surrounding language, unlike a traditional |> which only can compose functions.

Additionally in terms of actual engine implementation and in terms of the things a user can do with this new syntax, it's more complex. There will be reams of edge cases and blog posts demystifying it's use in corner cases, because it's wide open in what it permits. There'll be deopts for years to come because it will be so hard to predict behavior. Where as in lining a composition pipeline is far more straight forward.

You are right simplicity is a trade off. I think this design is elegant given the constraints. But that's why I'm questioning the justification for those constraints in #86.

I also do not want to have to include a placeholder for every composition, particular when my functions are already unary anyway and I use composition exhaustively. I'll have ? all over my code base to support a use case I don't have, and that I'm not convinced is a good idea anyway. It feels like a repeat of A+ where we forever inherit awkward code because of a supposed need the community didn't ask for.

Yes shipping |> without await may inhibit future designs, but it doesn't rule out the possibility of supporting await. Especially if await expressions are banned from pipeline in the interim.

@dead-claudia
Copy link
Contributor

dead-claudia commented Jan 23, 2018

Edit: Made a few small clarifications.

Here's my thoughts on await:

  1. Why special case it to partial application, if it only works within async functions?
  2. It could easily be "shimmed"* for non-async contexts through const then = f => async (...args) => f(...args), although this wouldn't translate well to things like generators/etc.

Now, to resolve 1, you could alter await behavior to work this way:

  • x |> (await f) does what you'd expect: it awaits f and invokes x with the awaited result.
    • Parentheses are required if you want to directly call the previously awaited result.
  • x |> await f is equivalent to await f(x).
    • This keeps partial application separate from the pipeline operator.
  • x |> await f is only valid within async functions for obvious reasons.

I know that complicates a potential use case, but it does so in favor for another more frequent one.

Similarly, I'll propose a few more useful additions:

  • x |> yield f is equivalent to yield f(x), available in generators only.
  • x |> yield* f is equivalent to yield* f(x), available in generators only.
  • x |> do ..., etc. perform the right hand side (whether it be a function, await, yield, or yield*), but return the argument passed in, not the result.
    • This lets you do things like call a void function or yield a result mid-flight, while still being able to continue the pipeline with the same value.
      • This would come in handy with array manipulation and DOM manipulation.
    • There's substantial precedent in libraries:
      • Consider the common tap function/method available in numerous utility libraries (like Lodash, RxJS, Underscore, Bluebird, among others).
      • A lot of each utility functions return the instance iterated.
      • Consider the vast number of fluent chaining APIs (notably jQuery here).
    • If you've got a better keyword than do, I'm flexible. (There is void, a little-known keyword that could see a little more use, but that's two more characters I have to type...)
    • Note: this can only be implemented as a function if await/yield/etc. aren't available within pipelines.

* I mean this exceptionally loosely, in the sense that es5-sham "shims" Object.getOwnPropertyDescriptor.

@zenparsing
Copy link
Member Author

@isiahmeadows

The "Hack-style" proposal here isn't trying to introduce any kind of partial application to the pipeline operator. Under this "Hack-style" proposal, the pipe operator simply creates a constant variable binding for the left operand when evaluating the right operand. Also, see #83 for a longer discussion on the await syntax as it fits into the current (implicit-call) semantics.

@JAForbes

I'll have ? all over my code base to support a use case I don't have

But other people will have those use cases. Should we make the syntax unergonomic/confusing for them?

Also, are we sure that we even need syntax for the implicit-call semantics? If you are really using composition for everything, can we use a function for that?

let result = purePipe(anArray,
  pickEveryN(2),
  filter(f),
  shuffle,
  map(f),
  Promise::all, // Future method-extraction syntax
  then(xs => xs.forEach(console.log)),
);

You could even combine that with |>:

anArray 
  |> purePipe($)
  |> $( pickEveryN(2),
        filter(f),
        shuffle,
        map(f) )
  |> Promise.all($)
  |> await $
  |> $.forEach(console.log);

@zenparsing
Copy link
Member Author

@gilbert

I think this is important to address since I imagine some will forget to pass $ into a righthand expression.

For this reason I would say that a syntax error is probably the best option.

@mAAdhaTTah
Copy link
Collaborator

mAAdhaTTah commented Jan 23, 2018

Is a placeholder required for unary functions? Is there a reason we couldn't allow mixed-use? Modified example from OP w/ ? instead:

anArray
  |> pickEveryN(?, 2)
  |> ?.filter(...)
  |> shuffle // Doesn't require the ?
  |> ?.map(...)
  |> Promise.all // neither does this
  |> await ? // maybe even drop it here?
  |> ?.forEach(console.log);

Are we mostly concerned here about developer confusion?

@ljharb
Copy link
Member

ljharb commented May 4, 2021

@kaizhu256 and again, please stop making those kind of comments; reassignment is not a good practice to everyone, and there is a need for many of us, even if you don’t have one.

There is nothing ergonomic about a list of statements that all reassign to the same variable.

@kaizhu256
Copy link

yes it is more ergonomic. javascript development is messy with entire codebases frequently rewritten every week (or every day) as a product's ux-wiring and ux-kinks are worked out.

pipeline-operators, and needless abstractions like filterBy, sortBy, ... when builtin array methods would do just fine, simply get in the way of the endless javascript code-rewrites product-developers have to deal with, as the ux evolves.

@MrVichr
Copy link

MrVichr commented May 4, 2021

There is nothing ergonomic about a list of statements that all reassign to the same variable.

On the other hand, I'm starting to wonder what's bad about dot-chaining. JavaScript is an OO language after all, not a functional one. OO tries to keep functions organized; most pipe examples I see are using global functions.

Why are stream libraries moving from dots to pipe()?

let result;
result = await fetch();
...
result = Array.from(result).
  filter(function (elem)
   { return elem.age >= 18 && elem.age <= 65 }).
  sort(function (aa, bb) {
    aa = aa.rating;
    bb = bb.rating;
    return aa-bb;
   }).
  reverse().
  slice(0, 100).
  map(function (elem) { return elem.age; });

I mean, there are a few cases where a pipe would be useful, in particular when I need the value more than once.
bigfunc(x) |> sendmail(#.subj, #.body)
But it's not like every other line of code would have a pipe in it.

@kaizhu256
Copy link

dot-chaining is generally the "right" way to transform arrays and strings in javascript (when builtin methods will suffice).

for the bigfunc(x) example, again, use a tmp-var:

let result;
result = ...
result = bigfunc(result);
result = await sendmail(result.subj, result.body);
result = ...

above code is easier to refactor than your pipeline example in the endless javascript-churn we all deal with.

@ducaale
Copy link

ducaale commented May 4, 2021

@MrVichr I think the main reason for it is tree shaking. From RxJS docs https://v6.rxjs.dev/guide/v6/pipeable-operators

Problems with the patched operators for dot-chaining are:

  1. Any library that imports a patch operator will augment the Observable.prototype for all consumers of that library, creating blind dependencies. If the library removes their usage, they unknowingly break everyone else. With pipeables, you have to import the operators you need into each file you use them in.

  2. Operators patched directly onto the prototype are not "tree-shakeable" by tools like rollup or webpack. Pipeable operators will be as they are just functions pulled in from modules directly.

  3. Unused operators that are being imported in apps cannot be detected reliably by any sort of build tool or lint rule. That means that you might import scan, but stop using it, and it's still being added to your output bundle. With pipeable operators, if you're not using it, a lint rule can pick it up for you.

  4. Functional composition is awesome. Building your own custom operators becomes much easier, and now they work and look just like all other operators in rxjs. You don't need to extend Observable or override lift anymore.

@fabiosantoscode
Copy link

@mAAdhaTTah yes totally agree there!

@highmountaintea
Copy link

@kaizhu256 and again, please stop making those kind of comments; reassignment is not a good practice to everyone, and there is a need for many of us, even if you don’t have one.

There is nothing ergonomic about a list of statements that all reassign to the same variable.

I agree. I have tried to use the temp-variable assignment method in practice, and learned quickly that it's not a good replacement for the Hack proposal.

@highmountaintea
Copy link

Why do we need new syntax to specify a binding when we have arrow functions?

Hi @mAAdhaTTah,

I added the bind token syntax partially to make the short form easier to define. It also makes the canonical form a little more precise. With the short form, we won't need to use the canonical form as much, so we can afford to be a little more verbose. But you could be right, and people might prefer the convenience of binding it to an implicit token.

There is still some difference between the token-binding syntax here and the F# proposal + arrow functions, because the token-binding syntax here works with generators and awaits, and have no performance penalties.

@mAAdhaTTah
Copy link
Collaborator

@highmountaintea I understand the thought process. I'm just pointing out that in doing so, you're reducing/eliminating one of the main advantages Hack has over F# (terseness) while compounding one of its main downside (novel syntax).

@highmountaintea
Copy link

I'm wondering if it would be useful to access previous bindings as well, or if it would be more confusing.

await fetch(url)
  ~>(res) await res.json()
  ~>(body) [res.ok, body.data]

It's an interesting idea. Seems versatile, but leaking the variable scope into the next pipe might feel like an issue.

@fabiosantoscode
Copy link

It probably is an issue. It's no longer easy to read since you have to go make sure bindings aren't reused below, unless you use the same name in every binding.

@aadamsx
Copy link

aadamsx commented May 4, 2021

No, I mean, literally the F# proposal:

let ageMap = fetch()
  |> await
  |>($) => filterBy($, x => x.age >= 18 && x.age <= 65)
  |>($) => sortBy($, x => x.rating, 'desc')
  |>($) => pickN($, 100)
  |>($) => mapBy($, x => x.age)

The whole purpose of the base Hack proposal is using # (or whatever token is decided) is terser than using arrow functions. If you start adding extra syntax to choose the token (e.g. "Hack Prime"), you lose the primary value of Hack, and you're add a lot of new syntax to do what F# already does "built-in" (because arrow functions are already a thing).

Why can't we get a champion here that will push the simpliest, minimal, F# proposal forward without this needless spinning our wheels all these years @mAAdhaTTah? We can always build on the minimal foundation as needed.

@mAAdhaTTah
Copy link
Collaborator

@aadamsx I've been working on this for 3+ years. Daniel was championing it most of that time and preferred F#. Tab has picked it up now and prefers Hack. The problem is not the champion, it's consensus.

@fabiosantoscode
Copy link

fabiosantoscode commented May 5, 2021

I think this is an instance of the law of triviality at work.

I'm guilty of it too, in this instance and others. But at the end of the day, does it matter, really?

I suspect that in the future we'll have linters and peer pressure stopping us from doing crazy things in pipes. Having worked at an elixir shop I was under strict guidelines on what to do and not to do, because if you overuse the pipe operator things get unreadable pretty fast.

So whatever syntax we choose, people will use it in the same way. It's just a matter of balancing power for crazy devs like me, with how much we need to teach new JS developers to get them up to speed on this syntax.

@aadamsx
Copy link

aadamsx commented May 5, 2021

I've been working on this for 3+ years. Daniel was championing it most of that time and preferred F#. Tab has picked it up now and prefers Hack. The problem is not the champion, it's consensus.

The problem is there will NEVER be a consensus on this! The TC39 guys have also aluded to the process not being based on concensus. So a champion leaning one way or another is the only thing I think that can tip the scales one way or another.

You've been on this from the start, why can't YOU be the champion here @mAAdhaTTah?

@aadamsx
Copy link

aadamsx commented May 5, 2021

I suspect that in the future we'll have linters and peer pressure stopping us from doing crazy things in pipes. Having worked at an elixir shop I was under strict guidelines on what to do and not to do, because if you overuse the pipe operator things get unreadable pretty fast.

Making up for flawed syntax with linters -- not a great idea @fabiosantoscode

@ljharb
Copy link
Member

ljharb commented May 5, 2021

TC39 is exclusively based on consensus; I’m not sure where anything else has been alluded to.

@aadamsx
Copy link

aadamsx commented May 5, 2021

Who is to say when we've reached a concensus other than the champion and/or TC39? For example @ljharb, could Tab take champion status over this repo and move forward with Hack in this repo's current state (when we obviously do not have concensus)?

From what I've read on this repo, TC39 takes our input to an extent, but ultimatly the decision is in their hands, and a concensus is not required for them to make a decision on way or another. I'll have to do searches for these types of posts, but don't have that much time today.

@ljharb
Copy link
Member

ljharb commented May 5, 2021

Consensus is among tc39 delegates, so yes, if every delegate agreed with a direction despite the concerns of participants on this repo, then it would move forward. However, i think it’s unlikely that the committee would agree to proceed with anything if there remains this much contention here.

In other words, in the absence of a compelling and persuasive argument that can override aesthetic preferences, if one group doesn’t cave, neither group may get the feature.

@mAAdhaTTah
Copy link
Collaborator

mAAdhaTTah commented May 5, 2021

The problem is there will NEVER be a consensus on this!

Not with that attitude!

I'm being flippant but ES5 took like a decade after ES3 was released to come to an agreement. ES6 took another 6 years. Even optional chaining, which is far simpler than pipeline, took like 4 years. Some features never advance (bind operator), some features go through multiple rounds of revisions (decorators) or last minute changes (globalThis). When I got started with this, I knew going in this was going to be a multi-year process with no guarantee of success. Designing features for a language used as broadly and in as wide a variety of contexts and styles as JavaScript was never going to be easy. Once features land, there's no going back, so the fact that this takes time is a feature of the process, not a bug.

You've been on this from the start, why can't YOU be the champion here @mAAdhaTTah?

I'm not a TC39 member.

@highmountaintea
Copy link

The biggest thing I'm taking away from this idea is maybe we need two operators 🙂

Funny but potentially true :) I picked two operators to illustrate my point, but it might be possible to merge the two operators into one.

It depends on how intuitive the overall feature is in practice. Two operators might seem more expensive than one operator, but it might not be so in terms of mental load if the two operators are similar (e.g. ~> and ~>>). > and >= are two different operators; + and += are two different operators. I am not saying the situations are exactly the same, just that decisions are usually based on practicality.

@fabiosantoscode
Copy link

@aadamsx

Making up for flawed syntax with linters -- not a great idea @fabiosantoscode

Exactly. This was an argument for the F# proposal, which does less, but is more predictable and less like something people would write lint rules for.

@davidvmckay
Copy link

davidvmckay commented Jul 14, 2021

I think @JAForbes makes an excellent point

Let's separate and generalize Hack's alternative syntax as a new, terser alternative to Fat Arrow syntax.

Anywhere a Fat Arrow function would work, an expression containing the Hack-style argument reference token should work.

We can call them "implicit lamdas" and they can be N-ary and N-order.

  1. '|>' ALWAYS takes unary function from RHS and applies it to LHS -- we can delivery this today without further debate
  2. any expression with bound # treats # as it was bound
  3. any expression with unbound # is auto-expanded to fat arrow (#0) => myExpression(#0)
  4. auto-expanded fat arrows can support N arguments with an index notation
    1. sum([#, #1, #2]) becomes (#0, #1, #2) => sum([#0, #1, #2])
  5. nested auto-expansion is distinguished from outer bindings by repeating the token
    1. [1,2,3,4].flatMap(arrayOfLength(#).map(##1 * 10 + #)) expands to [1,2,3,4].flatMap((#0) => arrayOfLength(#0).map((##0, ##1) => ##1 * 10 + #0)) which yields [1,2,3,4, 11,12,13,14, 21,22,23,24, 31,32,33,34]
      1. i.e., a first-order unary implicit lambda composing a second-order binary implicit lambda whose first argument (array element supplied to map callback) is ignored and second argument (array index supplied to map callback) is utilized.
      2. Explicit lambdas could probably be mixed in there too: [1,2,3,4].flatMap(arrayOfLength(#).map(##1 * 10 + #).filter((#0) => #0 !== #).filter((#) => # !== #))
        1. ... the first filter explicitly binds #0, but the comparison with # works because the expansion from # to (#0) => #0 is semantic, not syntactic; if we stopped here, this first filter would drop the first 4 elements from the end result of the total example expression.
        2. ... second filter explicitly binds #, so filtering # !== # will always evaluate to false, causing the end result of this total example expression to be []
        3. This implies that # alone is now the identity function. 🤓
[1,2,3,4,5,6,7,8,9]
  |> pickEveryN(#, 2)
  |> #.filter(# => 0 === # % 4) // `#` in filter lambda explicitly rebinds the token to disambiguate with outer.
// next 4 lines are equivalent, but allow for different nuances in the semantics:
  |> shuffle // just a unary function
  |> # => shuffle(#) // a unary lambda, which manually binds `#`
  |> #0 => shuffle(#0) // a unary lambda, which manually binds `#0`
  |> shuffle(#) // interpreted as `(#0) => shuffle(#0)` which is a unary lambda
  |> #.map({[##]: `value was ${await dbLookup(##)}`}) // first-order unary lambda of `#0` composing second-order unary lambda of `##0`
  |> Promise.all // unary function
  |> await // operators are just unary functions
  |> forEach(console.log) // TYPE ERROR, assuming no forEach is in scope other than Array.prototype.forEach, which is not called because there is no dot operator.
// Should be `#.forEach(console.log)` or `#.forEach(console.log(##)` or some explicit lambda version of those

@aadamsx
Copy link

aadamsx commented Jul 15, 2021

The Hack style is just an awful looking compare to F# style.

@davidvmckay
Copy link

davidvmckay commented Jul 20, 2021

The Hack style is just an awful looking compare to F# style.

Beauty is in the eye of the beholder,

but conciseness / terseness / signal-to-noise ratio are objectively measurable by information-theoretic techniques independently discovered by civilizations separated across the farthest reaches of space-time.

Over Fat Arrow syntax, Hack syntax offers the benefit of several characters savings, and savings of 2 to 4 tokens depending on parentheses, while also promoting a standard convention for referencing "the obvious parameter binding" that once learned by devs, reduces cognitive load in mentally binding the symbol to the meaning.

As long as we can still use Fat Arrow when preferred, the availability of Hack shorthand has a lot to offer in reducing the amount of mental effort devs spend on "pretending to be a human compiler" while navigating the code with their eyes.

My main suggestion is that the argument in favor of Hack syntax can be disentangled from the design of a functional application operator ( also called infix, or pipeline |> operator ) if we recognize how any unary function syntax can be used.

We could ship |> without further syntax bikeshedding if we just limit RHS to be "any valid unary function" and sidestep discussions of special syntax.

(Context-sensitive syntax in general has complexity and generalizability drawbacks anyway.)

Then, a separate design discussion can be had about whether to introduce Hack-style shorthand for function definitions.

@lightmare
Copy link

Hack-style shorthand for function definitions.

An important aspect of this proposal is that it is not a short-hand for function definition.

@theScottyJam
Copy link

@davidvmckay - that idea is being discussed over here. The issue is that as soon as we cross the road of doing F#-style, there are many nice benefits to hack-style that would become inaccessible to us. It's not an easy task to turn the topic idea into a general-purpose concept, and doing so in a reasonable way adds so many restrictions as to render it much less useful - something I talked a little more about in that thread here.

So, even if we decide to go the F# route, we still have to be absolutely sure that the current hack-style idea is not what we want, because there's no turning back once we've picked a path.

@tabatkins
Copy link
Collaborator

Closing, as the proposal has advanced to Stage 2 with this syntax.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Oct 12, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests