tc39 / proposal-pipeline-operator

A proposal for adding a useful pipe operator to JavaScript.
http://tc39.github.io/proposal-pipeline-operator/
BSD 3-Clause "New" or "Revised" License
7.5k stars 108 forks source link

Effect of Hack proposal on code readability #225

Closed arendjr closed 2 years ago

arendjr commented 2 years ago

Over the past weekend, I did some soul searching on where I stand between F# and Hack, which lead me into a deep-dive that anyone interested can read here: https://arendjr.nl/2021/09/js-pipe-proposal-battle-of-perspectives.html

During this journey, something unexpected happened: my pragmatism had lead me to believe Hack was the more practical solution (even if it didn't quite feel right), but as I dug into the proposal's examples, I started to dislike the Hack proposal to the point where I think we're better off not having any pipe operator, than to have Hack's.

And I think I managed to articulate why this happened: as the proposal's motivation states, an important benefit of using pipes is that it allows you to omit naming of intermediate results. The F# proposal allows you to do this without sacrificing readability, because piping into named functions is still self-documenting as to what you're doing (this assumes you don't pipe into complex lambdas, which I don't like regardless of which proposal). The Hack proposal's "advantage" however is that it allows arbitrary expressions on the right-hand side of the operator, which has the potential to sacrifice any readability advantages that were to be had. Indeed, I find most of the examples given for this proposal to be less readable than the status quo, not more. Objectively, the proposal adds complexity to the language, but it seems the advantages are subjective and questionable at best.

I'm still sympathetic towards F# because its scope is limited, but Hack's defining "advantage" is more of a liability than an asset to me. And if I have to choose between a language without any pipe operator or one with Hack, I'd rather don't have any.

So my main issue I would like to raise is, is there any objective evidence on the impact to readability of the Hack proposal?

Pokute commented 2 years ago

But some would apparently rewrite it like this:

const headers = { Accept: “text/csv” };
if (token) {
    headers.Authorization = `Bearer ${token}`;
}

return `${baseUrl}/data/export`
    |> await fetch(^, { headers })
    |> await ^.text()
    |> parseCsv(^);

I would write it more as following:

return `${baseUrl}/data/export`
    |> await fetch(
      ^,
      {
        Accept: “text/csv”,
        ...token && { Authorization: `Bearer ${token}` }
      }
    )
    |> await ^.text()
    |> parseCsv(^);

I don't see much point in constructing stuff like the options bag separately. It's not very pretty though since the syntax for adding optional members into objects is a bit clunky. I wish this was possible:

({
  Accept: “text/csv”,
  Authorization?: token && `Bearer ${token}`,
})

Actually, I don't see why it shouldn't be possible. This makes me think...

shuckster commented 2 years ago

Actually, that should be possible. This makes me think...

I think you're looking for this:

{
  Accept: "text/csv",
  ...(token && { Authorization: `Bearer ${token}` })
}

This is wildly off-topic though, so to bring it back round, my own take on this example is missing something completely relevant to the topic at hand: the name of the function:

function fetchCsv() {
  const headers = {
    Accept: 'text/csv',
    ...(token && { Authorization: `Bearer ${token}` }),
  }
  return fetch(`${baseUrl}/data/export`, { headers })
    .then(res => res.text())
    .then(parseCsv)
}

Anyway, onto |> comparisons:

Hack:

async function fetchCsv() {
  const headers = {
    Accept: 'text/csv',
    ...(token && { Authorization: `Bearer ${token}` }),
  }
  return await fetch(`${baseUrl}/data/export`, { headers })
    |> await ^.text()
    |> parseCsv(^)
}

F#:

async function fetchCsv() {
  const headers = {
    Accept: 'text/csv',
    ...(token && { Authorization: `Bearer ${token}` }),
  }
  return fetch(`${baseUrl}/data/export`, { headers })
    |> await
    |> res => res.text()
    |> await
    |> parseCsv
}

I don't know about you, but whenever I see async/await I'm lurched into "imperative error checking mode", where I'm forced to make a decision on where to place one or more try/catch statements.

This is a cognitive load that I'm completely free of in the "Pure Promise" version, because any thrown error will propagate down the chain to the point-of-use, rather than having to mess with errors in the utility function.

So for this example pipelines just seem to be completely the wrong tool for the job. Even without the async/await try/catchery, the extra syntax of the Hack version is hardly worth it, and the F# version is obviously awkward, and for me just serves to reinforce the principles of "less is more" and "limitation begets creativity". Any pipeline operator should serve FP, not Promises.

Just because Hack can "do everything" via expressions doesn't mean it's the right choice.

Pokute commented 2 years ago

Just because Hack can "do everything" via expressions doesn't mean it's the right choice.

This always happens with me too: whenever I get a new tool, it becomes the solution for everything. When you get a new favourite hammer, everything looks like a nail for a while. It's not only a problem with pipelines. It takes a little bit until I realise that screwdrivers are still better tools for screws. This is not a good reason to not get new tools though.

I don't know about you, but whenever I see async/await I'm lurched into "imperative error checking mode", where I'm forced to make a decision on where to place one or more try/catch statements.

This is a cognitive load that I'm completely free of in the "Pure Promise" version, because any thrown error will propagate down the chain to the point-of-use, rather than having to mess with errors in the utility function.

This is another of those tools where I've gotten overboard. I'd look at everything that can throw and then wrap everything in try-catch. The usage of await, callback or pipelines makes no difference. I think it's too eager exception handling. Everything has the same concerns and the point with exception is to look whether you can do something useful about the exception. Is there a valid fallback for fetch failing? Maybe a retry. Is there a valid fallback for await res.text() failing? Is it any different from the fallback from fetch failing - not really so they should use the same try-catch block. There usually is no reason to bother the user with different error messages in either of these cases either.

This, in my opinion, was just a case of not being familiar with handling exceptions. In even this case, I would not add retrying functionality to fetchCsv since the retrying is generic and rather I would use a higher-order-function like withRetries(fetchCsv, 3) that handles the thrown exceptions. There's no real reason why fetchCsv should try handling exceptional situations by itself.

lightmare commented 2 years ago

@shuckster

This is a cognitive load that I'm completely free of in the "Pure Promise" version, because any thrown error will propagate down the chain to the point-of-use, rather than having to mess with errors in the utility function.

Your non-async fetchCsv is evil. It can throw an error immediately, or return a promise, that can later become rejected.

shuckster commented 2 years ago

That's good clarification @Pokute , and I agree with it. I'm just speaking anecdotally about the try/catch habit.

@lightmare - Good catch thank you, and resolved with an async prefixing the function. This does make me reflect on my try/catch habit, so I take that back, but I stand by the part of the post that questions the need for a pipeline in the first place.

lozandier commented 2 years ago

I have to bow out of these discussions indefinitely to accommodate NBU stakeholders in the next few days; accordingly, allow me to provide an actual solution before I'm absent in these discussions for a while:

To recap, my stance is that a pipeline operator that allows any valid right hand side (RHS) expression is admirable, but that end shouldn't be accommodated at the expense of first-class composition–the most essential and common representation of pipelining–be syntactically harder to communicate than such expressions.

While these RHS expressions accumulated are more prevalent in JS codebases , they are unorthodox things to pipe with the traditional mental model of what pipelining is ubiquitously understood as being conceptually.

Pipeline is ubiquitously understood conceptually in data science and computer science as a series of tasks that will be executed, in order, when data becomes available immediately or over time.

In other words, a pipeline is a series of functions that are invoked with the result of the previous function that preceded it or an initial value3.

The simplest way most languages represent a series of logic blocks/tasks/subroutines that can simultaneously take in a result of a previous series of logic blocks/tasks/subroutines as starting input is a unary function.

This ubiquitous understanding of the pipeline paradigm is why an overwhelming amount of userland JS libraries designed to facilitate better dev UX working with functions have conformed to this paradigm with their abstractions insinuating association with pipelining.

This is also the reason pipe operators in languages such as Bash, Haskell, Julia, are represented as they are.

In its aims of supporting any expression to linearize, Hack-style unorthodox representation of pipelining requires massive rewriting of userland representation of pipelining that has adopted this ubiquitous mental

That said, I do see value of hack-style existing alongside the ubiquitous representation of pipelining desired in the language to the extent it has become a reoccurring common ask by the JS community.

Accordingly, my stance is the following solution below that I think would be a major breakthrough for the proposal to be desired to be passed by the JS developer demographic that wanted it in the first place in addition to the more generic handling of linearizing expressions desired by the TC39 community.

My proposed solution for dramatically alleviate readability concerns

I believe we should have two pipeline operators

1) |> (Explicit/Tacit pipelining): Pipelining that data scientists1, mathematicians, everyday users of POSIX compliant shells, and other existing practitioners of solving problems in a pipeline-oriented manner are familiar with and love. |> explicitly communicates you are passing data (or a result of a previous function) to the following function.

2) |>> (Implicit pipelining)**: need to make implicit pipelining explicit with (^); this variation of pipelining enables any expression to be pipelined. Anonymous pipelining is occurring with expressions being evaluated to return a result that is then piped explicitly or implicitly further.

This solution allows things like await and yield, that are awkward, uncommon fits to generic pipelining with regular functions be more easily integrated in existing, established handling of pipeline-code without extra syntactical tokens for pipelining associated with ubiquitous representation of pipelining through functions that meaningfully react to unary invocations2.

Being able to suddenly linearize/pipe arbitrary RHS expressions with just the use of an additional > character (or an alternate token representing Hack-style) can be quite fluent and useful to all developers with varying familiarity of programming in a pipeline-oriented manner:

Here are some straight forward examples

// Explicit pipelining 
2 |> double |> console.log
// Implicit pipelining 
"Charlie" |>> await loadProfile(^) |>> {users: [(^)] } 
// Example mixing the two pipelines ;
"Avery" |>> `Hey, ${^}! ` |> repeatWithDeliminator(' ')  // result: Hey, Avery! Hey, Avery!; No need for repeatWithDeliminator(' ')(^)
// Example in Read.me from react/scripts/jest/jest-cli.js with the two pipeline operators utilized
envars
|> Object.keys
|>> ^.map(envar => `${envar}=${envars[envar]}`)
|>> ^.join(' ')
|>> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' '))
|> console.log;

Having |> and |>> allows developers/teams/entities a choice to represent problems that are very important to them in a pipeline-oriented pattern they prefer and can more intuitively enforce and rely on–especially important in environments where every byte matters getting in the way of importing libraries with pipe() and so on.

If I'm recollecting the past correctly, @js-choi suggested before that explicit piplining (F#-style/minimal style) be associated with |>> . There's several problems with this associated with the primary purpose of a pipeline operator (and an operator in general) to a very meaningful amount of people who solve pipeline-oriented problems today with a ubiquitous understanding that the operator tacitly communicates first-class composition:

Hack-style has no qualms/concern adding additional characters to be able to support generic RHS expression being linearized , so why would it not bear an extra character (>) tax to activate instead of the ubiquitous tacit intent of pipelining to linearize functions represented by the F-style/minimal-style in this proposal repo historically?

In conclusion, the pipeline behavior that emphasizes tacit representation of pipelining should own the most tacit representation of pipelining unanimously desired thus far– |>–while the more verbose, unorthodox representation of pipelining should adopt a more verbose representation of pipelining represented by my solution as |>> (which many programming typefaces already provide ligatures for in addition to |>).

I can understand if some existing champions seeing this being very similar to smart mix. At minimum, I'm very certain that attempt wasn't framed this way at all. I also think that attempt did not account for the testimonies of I and other healthy communicators of the importance and ubiquity of the pipeline operator tacitly communicating first-class functional composition being intact with this operator (and what it manifests as alternate takes on pipelining such as |>>) towards the operator actually being natively supported by the language.

Any cons of this approach I'd like to directly address

The only explicit con of this solution to me is that engine implementers are adding two new operators JS developers are looking forward to; I've seen them tackle worser problems.

As far as readability cons, I see readability concerns dramatically being reduced with this approach that simultaneously allows the freedom of any expression being linearized while allowing the ubiquitous nature of pipelining prevalent in data science, mathematics, and by a meaningful amount of the JS community intact.

I think the differences between |> vs |>> to be very easily understandable and teachable. I would consider |>> intermediate with |> being intuitive for those seeking to casually pipe within the language right away.

I anticipate the contentious objections and unwarranted vitriol this proposal has had the past 3 years practically disappear overnight. I also believe prominent maintainers of extremely popular libraries that have embraced the value of solving problems with pipelines (and made more accessible such a paradigm to the language to others) such as @benlesh would be very happy with this solution.

Conclusion

JS in general has grown to be a ubiquitous language to rely on, and it would be great for problems involving things like data processing be able to be solved in a pipeline manner natively in JS by pipeline operators.

That said , the Hack-style becoming effectively the "default" behavior for pipelining in the language via |> I believe will create unnecessary cognitive noise because of Hack-style's unorthodox handling of pipelining that is contrary to how pipelining is understood conceptually by most.

To deliberately reiterate, it is ubiquitously understood pipelining is associated with functional composition, and that the most elementary representation of a unit of work to be applied against data in a pipeline matter is a unary function.

In my opinion unary functions–the simplest means of representing a process that acts on passed or initial data of a pipeline–should not be taxed with arbitrary additional syntax ((^)).

Being part of Google's responsible innovation team, I'm not only attempting to emphasize with the meaningful amount of JS developers that identify as "functional programmers" who may not have always communicated their objections the best way to the champions about this proposal (I emphasize being a minority member of the JS community) or data scientists who increasingly use JS to solve problems associated with artificial intelligence or machine-learning: I'm also advocating for general problem solvers who wish to solve their problems in a pipeline manner in the matter that the pipeline paradigm is ubiquitously understood as being in behavior.

If this was a memorable solution/moment for people worthy of being in a book someday, please let this solution be known as "Lozandier's pipeline operator compromise". :upside_down_face:

Footnotes

1There's a reason why data-science oriented languages like Julia prioritize unary functions with their existing pipeline operators

2 If tuples exist, it would be directly passed. Tuples currently proposed are array-like, so it'd be trivial to handle even as explicit as ^[0], ^[1]) to get the first and second value of a tuple.

3 What is explained in this solution and the past few hours I believe addresses @tabatkins's legitimate question about how ubiquitous is the association of pipelining with unary functions.

lightmare commented 2 years ago

@lozandier

// Example in Read.me from react/scripts/jest/jest-cli.js with the two pipeline operators utilized
envars
|> Object.keys
|> ^.map(envar => `${envar}=${envars[envar]}`)
|> ^.join(' ')
|>> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' '))
|> console.log;

Even though I'm not opposed to two operators (might've suggested that myself at some point), this is not how they should be utilized. I don't know whether you're suggesting bringing back the "Smart Mix" which was retracted (good riddance) or just forgot to change some |> to |>>, but either way: two wrongs don't make a right. Pick a style and use it. This is unreadable.

lozandier commented 2 years ago

@lozandier

// Example in Read.me from react/scripts/jest/jest-cli.js with the two pipeline operators utilized
envars
|> Object.keys
|> ^.map(envar => `${envar}=${envars[envar]}`)
|> ^.join(' ')
|>> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' '))
|> console.log;

Even though I'm not opposed to two operators (might've suggested that myself at some point), this is not how they should be utilized. I don't know whether you're suggesting bringing back the "Smart Mix" which was retracted (good riddance) or just forgot to change some |> to |>>, but either way: two wrongs don't make a right. Pick a style and use it. This is unreadable.

@lightmare Please share what exactly makes it unreadable to you when you next get the chance.

Edit: I actually did see typos I did confusing you about my original intent:

Corrected my original last example of what I'm proposing to be

envars
|> Object.keys
|>> ^.map(envar => `${envar}=${envars[envar]}`)
|>> ^.join(' ')
|>> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' ')) 
|> console.log;

Use of |> or |>> is of no consequence to chalk.dim(^, 'node', args.join(' ')) similar to '' vs `` for regular strings.

I personally would want to switch |>> to be $>. to be distantly related to ${} in order to communicate the manual templating you're doing to communicate a valid pipeable result with any valid RHS expression. I wonder what @js-choi thinks of that.

shuckster commented 2 years ago

@lightmare - I believe he made some typos:

envars
|> Object.keys
|>> ^.map(envar => `${envar}=${envars[envar]}`)
|>> ^.join(' ')
|>> `$ ${^}`
|>> chalk.dim(^, 'node', args.join(' '))
|> console.log;

So 4x |>> and 2x |>, right?

Just because this is an alternate version of "Smart Mix" doesn't mean that |>> and |> will generally be mixed in practice.

FPers will stick with |>, tokeners will stick with |>>, and those who mix them will have their PRs rejected.

arendjr commented 2 years ago

While I personally still think less is more, I think @lozandier’s proposal is at least a step up from the current proposal. I would still set my own linter rules to tame that beast, but I see how this might at least satisfy both F# and Hack supporters.

shuckster commented 2 years ago

@lozandier

Use of |> or |>> is of no consequence to chalk.dim(^, 'node', args.join(' ')) similar to '' vs `` for regular strings.

I'm not overly clear myself on this point -- why would the chalk line not require a |>> if it's using ^ please?

lozandier commented 2 years ago

For

@lozandier

Use of |> or |>> is of no consequence to chalk.dim(^, 'node', args.join(' ')) similar to '' vs `` for regular strings.

I'm not overly clear myself on this point -- why would the chalk line not require a |>> if it's using ^ please?

For both |> and |>>/$> you may want to be able to partially apply an initial value or the result of a previously completed subroutine/expression.

Accordingly, I think ^ should be considered allowable with both. Similar to what I demonstrated before, a developer can feel free to do

x |> multiply(2, ^) |> add(10, ^) // when no longer a function , the non-functional result is piped to the next unit of work or returns the value if the last unit of work; that's the case of multiply(2, ^) and add(10, ^) if both only accept 2 params. 

or (multiply and add being configured to be curryable)

x |> multiply(2) |> add(10)

or (point-free)

x |> double |> add10

When doing conventional/explicit pipelining with |> I don't think simple partial application use cases of passing an initial value to a parameter that's not the first param of the following subroutine/task that isn't unary should necessitate switching to |>>.

It's of equal value to both forms of pipelining.

shuckster commented 2 years ago

Ah, I missed the part where you said that ^ works as a PFA token for |>.

PFA has its own proposal, which reduces my optimism that Smart Lozandier Mix will be an acceptable compromise here.

And so does this:

One of the original goals for partial application was to dovetail with F#-style pipelines using minimal syntax

runarberg commented 2 years ago
envars
|> Object.keys
|>> ^.map(envar => `${envar}=${envars[envar]}`)
|>> ^.join(' ')
|>> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' '))
|> console.log;

I’ve found a lot of the examples use to favor the hack pipelines are trivial to linearize by simply using the dot operator (and with the partial application proposal):

`$ ${
  Object.entries(envars)
    .map(([key, value]) => `${key}=${value}`)
    .join(' ')
}`
  |> chalk.dim(?, 'node', args.join(' ')) 
  |> console.log;

Although I would probably write a utility function here prefixString:

function prefixString(prefix, string) {
  return `${prefix}${string}`;
}

Object.entries(envars)
  .map(([key, value]) => `${key}=${value}`)
  .join(' ')
  |> prefixString('$ ', ?)
  |> chalk.dim(?, 'node', args.join(' ')) 
  |> console.log;
lozandier commented 2 years ago

this

I may be

Ah, I missed the part where you said that ^ works as a PFA token for |>.

PFA has its own proposal, which reduces my optimism that Smart Lozandier Mix will be an acceptable compromise here.

And so does this:

One of the original goals for partial application was to dovetail with F#-style pipelines using minimal syntax

AFAIK, I think hack-style has the same problem amidst that proposal existing… Am I misunderstanding the use of ^ in hack-style examples as a PFA token?

Also note there's recent traction to replace the existing ^ PFA token used with hack-style examples to be % as the PFA token used; I'm merely continuing to use ^ to be consistent with recent use of that token still within this thread.

noppa commented 2 years ago
x |> foo(bar(^))

means different things in Hack (foo(bar(x))) and F#+PFA (foo(_ => bar(_))(x)). If you allow topic in RHS of |> (as opposed to just allowing it with |>>), it's confusing which one of these behaviours is applied.

lozandier commented 2 years ago
x |> foo(bar(^))

means different things in Hack (foo(bar(x))) and F#+PFA (foo(_ => bar(_))(x)). If you allow topic in RHS of |> (as opposed to just allowing it with |>>), it's confusing which one of these behaviours is applied.

x |> foo(bar(^)) is very much directly saying foo(bar(x))(x) to me; that should be different than what Hack resolves to (foo(bar(x)).

I'm having a hard time emphasizing with you on the confusion here.

noppa commented 2 years ago

Oh okay, if it works like that then it's consistent with how the Hack works and there's no confusion, great. Perhaps I was confused about the mention of PFA, because I very much hope we get proper standalone PFA at some point that works more like the second of those examples, i.e. so that arr |> map(^, get(^, 'foo.bar.baz')) works like it would've worked with F#+PFA

shuckster commented 2 years ago

Am I misunderstanding the use of ^ in hack-style examples as a PFA token?

Since Hack works with expressions there's no real partial function application actually happening.

Take:

'foccacia' |> console.log(^)

As far as I can determine Hack will treat this as:

console.log('foccacia')

What I would expect if this was run as a pipe with actual PFA:

;(x => console.log('foccacia')(x))('foccacia')

Which essentially results in a TypeError in trying to run console.log()() because the F in PFA is a capital "F" in these cases, and a proper functional pipe will try to call it after the application. Which I guess is why you were suggesting relegating Hack to |>>? Since it's intent is to work with expressions, which are naturally more verbose, then it should get the more verbose token?

lightmare commented 2 years ago

@lozandier

Please share what exactly makes it unreadable to you when you next get the chance.

Edit: I actually did see typos I did confusing you about my original intent:

Yes, that's what I found most confusing — using topic placeholder in the tacit variant.

Use of |> or |>> is of no consequence to chalk.dim(^, 'node', args.join(' ')) similar to '' vs `` for regular strings.

x |> foo(bar(^)) is very much directly saying foo(bar(x))(x) to me; that should be different than what Hack resolves to (foo(bar(x)).

lightmare commented 2 years ago

I personally would want to switch |>> to be $>.

Not viable:

input
  $> foo
// is currently valid code, parses as
input;
$ > foo;
lozandier commented 2 years ago

@lozandier

Please share what exactly makes it unreadable to you when you next get the chance. Edit: I actually did see typos I did confusing you about my original intent:

Yes, that's what I found most confusing — using topic placeholder in the tacit variant.

Use of |> or |>> is of no consequence to chalk.dim(^, 'node', args.join(' ')) similar to '' vs `` for regular strings.

x |> foo(bar(^)) is very much directly saying foo(bar(x))(x) to me; that should be different than what Hack resolves to (foo(bar(x)).

  • either it's of no consequence, in which case x |> foo(bar(^)) means foo(bar(x)), i.e. "Smart Mix"
  • or it's the latter, foo(bar(x))(x), which seems even more confusing to me

It shouldn't be confusing following the composition flow communicated by typical pipelining. It so happens to involve a curryable function perhaps originating from multiple parameters [say a curried version of const add = (x, y) => x+y] and a unary function [say a parseInt10 function that's coincidently a curried version of parseInt(number, radix) in which the radix is already being set to 10]:

Supposed I wanted to parse a number and implicitly double it using a function of the form foo(bar(^)):

const result = "2" |> add(parseInt10(^)) // result: 4

That is equivalent to add(parseInt10(2))(2).

Note I don't ever really see this being actively pursued to be done in one line often by many beyond educational purposes. That said, I hope I've made it clear why the pipeline-oriented composition flow being communicated by x |> foo(bar(^)) is clear to me.

lozandier commented 2 years ago

Am I misunderstanding the use of ^ in hack-style examples as a PFA token?

Since Hack works with expressions there's no real partial function application actually happening.

Take:

'foccacia' |> console.log(^)

As far as I can determine Hack will treat this as:

console.log('foccacia')

What I would expect if this was run as a pipe with actual PFA:

;(x => console.log('foccacia')(x))('foccacia')

Which essentially results in a TypeError in trying to run console.log()() because the F in PFA is a capital "F" in these cases, and a proper functional pipe will try to call it after the application. Which I guess is why you were suggesting relegating Hack to |>>? Since it's intent is to work with expressions, which are naturally more verbose, then it should get the more verbose token?

Correct regarding your question; Delegating Hack to |>> makes sense for me so that the unorthodox style of pipelining can own its verbosity by requiring more characters to activate its utility.

The typical style of pipelining that deliberately intended to communicate first-class composition tacitly represented by the F#-styles/minimal-style's use of |> should require the fewest characters among pipelining operations in JS to activate its utility between the two.

lightmare commented 2 years ago

It shouldn't be confusing following the composition flow communicated by typical pipelining.

It's super confusing because there's nothing typical about using explicit topic reference and also a tacit function call.

lozandier commented 2 years ago

It shouldn't be confusing following the composition flow communicated by typical pipelining.

It's super confusing because there's nothing typical about using explicit topic reference and also a tacit function call.

I'm referring to the flow of pipelining being represented with first-class composition tacitly communicated, not the use of the expression raised by @noppa . You can trivially represent "2" |> add(parseInt10(^)) with one more pipe merely as

"2"
  |> parseInt10
  |> add(^)

This would be the far more common way of representing the code raised.

noppa commented 2 years ago

IMO the use of topic should be forbidden when used with the tacit call syntax for the same reason it's required with the hack-style; doing otherwise is most likely a mistake. Allowing it to be used with hack-style semantics but tacit call to the result doesn't bring the benefits of standalone PFA proposal to the table. It wouldn't enable one to use non-curried functions with pipelines without lambdas.

lozandier commented 2 years ago

IMO the use of topic should be forbidden when used with the tacit call syntax for the same reason it's required with the hack-style; doing otherwise is most likely a mistake. Allowing it to be used with hack-style semantics but tacit call to the result doesn't bring the benefits of standalone PFA proposal to the table. It wouldn't enable one to use non-curried functions with pipelines without lambdas.

Note the most essential unit of work in a pipeline is a lambda (a unary function). The result of a unit of work (tuple of not) is a singular outcome from the task that is directly passed to be the param of the next unit of work until the last unit of work in the pipeline is complete to return a result from the pipelining.

Furthermore |>> represented by Hack-style has you covered; |> is explicitly communicating explicit pipelining is going on, allowing it to be tacit.

Do you have a reason why you think |> by default should not explicitly communicate that the value of the left of the operator will directly be used to invoke the function that immediately follows the operator (what piping is to most) on the right?

noppa commented 2 years ago

What I mean is that, with F# pipelines and a non-curried function add, we would've had to write

x |> _ => add(_, 5)

This felt too verbose to some, so we got the partial application proposal, which would've allowed

x |> add(?, 5)

Neat. So if we now introduced the version of the pipeline discussed here, which uses both tacit calls and allows the use of topic placeholder, we can write

x |> add(^, 5)

all we want but it doesn't mean the same as the PFA example above, it means add(x, 5)(x).

This version allows something different - it allows one to use the LHS both when creating the function on RHS and then calling that function (implicitly) with that same value.

That's fine and all, but my argument here is that that's such a rare use-case that it doesn't warrant creating the refactoring hazard / readability concern between x |>> add(^, 5) and x |> add(^, 5), which look similar but mean very different things. I'd rather get an early error on the latter that it's not allowed to use the topic here, just like the former gives me an error if I forget to use it.

Edit: this discussion is probably putting the cart before the horse anyway, since the idea of having both |> and |>> isn't even accepted.

lozandier commented 2 years ago

What I mean is that, with F# pipelines and a non-curried function add, we would've had to write

x |> _ => add(_, 5)

This felt too verbose to some, so we got the partial application proposal, which would've allowed

x |> add(?, 5)

Neat. So if we now introduced the version of the pipeline discussed here, which uses both tacit calls and allows the use of topic placeholder, we can write

x |> add(^, 5)

all we want but it doesn't mean the same as the PFA example above, it means add(x, 5)(x).

This version allows something different - it allows one to use the LHS both when creating the function on RHS and then calling that function (implicitly) with that same value.

That's fine and all, but my argument here is that that's such a rare use-case that it doesn't warrant creating the refactoring hazard / readability concern between x |>> add(^, 5) and x |> add(^, 5), which look similar but mean very different things. I'd rather get an early error on the latter that it's not allowed to use the topic here, just like the former gives me an error if I forget to use it.

This error seems very self-inflicted to me:

x |>> add(^, 5) is implicit, unorthodox pipelining that is not directly pipining the result of the left of it to the expression on the right; you must explicitly pipe using ^.

x |> add(^, 5) is communicating explicit pipelining will occur with the result of add(^, 5) expected to be a function to then pass in x [add(x,5)(x)]. Ideally, if it's not a function, I would say the value evaluated by the expression prior to piping x (in this case x + 5 being a number) is returned to continue/finish the pipeline. If that's not possible (due to where this happens in the runtime?), I'll be somewhat OK with that being an error–the pipeline operator is ubiquitously understood as an operator to more easily communicate first-class functional composition after all.

I somewhat don't mind not immediately piping with |> if ^ is used to keep topic results the same between the two as before; that said, it's also not unprecedented to ban topic altogether with the use of |> to only allow |>> the ability to use ^.

I think when the result is no longer a function, the value is passed to the next unit of work represented in the pipeline or completes the pipeline if it was the value associated with the last unit of work in the pipeline chain declaration (initial value -> units of work that work on the result of the unit of work before it or initial value -> final non functional result after all pipeline tasks are complete).

Functions in a pipeline are expected to be fruitful functions (functions that return values) after all.

This allows "foo" |> 2, "foo" |> functionThatReturns2, "foo" |> functionThatReturns2(^), and "foo" |> functionThatReturns2() to all represent the same result of 2.

With the first one–since 2 isn't a function immediately following|>–you're effectively saying "I'm starting a pipeline with "foo", but I'm suddenly going to go ahead and use 2 instead".

With the second one, "foo" is passed into functionThatReturns2 that always ignores its argument object to return 2 as suggested by my naming of the function. 2 is returned accordingly.

With the third one, you sure say you're passing in "foo" into functionThatReturns2 before explicitly trying to pass in "foo". functionThatReturns2("foo") will always return 2 which isn't a function; not being a function "foo" isn't explicitly piped "foo" since it's not a function. 2 is returned accordingly.

With the 4th one, "foo" |> 2 behavior explained above is expected to still result in the value of 2 that completes the pipeline.

lightmare commented 2 years ago

This allows "foo" |> 2, "foo" |> functionThatReturns2, "foo" |> functionThatReturns2(^), and "foo" |> functionThatReturns2() to all represent the same result of 2.

When the expression on the right-hand-side of |> produces a non-function, what is more likely?

lozandier commented 2 years ago

This allows "foo" |> 2, "foo" |> functionThatReturns2, "foo" |> functionThatReturns2(^), and "foo" |> functionThatReturns2() to all represent the same result of 2.

When the expression on the right-hand-side of |> produces a non-function, what is more likely?

  • a) that the programmer deliberately wrote a pipe which ignores its input, and returns something unrelated
  • b) that the programmer made a mistake on the right-hand-side, and would appreciate an error thrown at that point

(A) without question; it's not uncommon to do/see this debugging, testing, and educationally showing essentially what a task without regard to the previous/initial value is doing in a typical pipeline. The result of that task is passed to the next task, or is the final result of the pipeline if it's the last task. It is not "something unrelated".

In other words, my example I just explained is the side effect of a fruitful function ignoring the passed parameters used to invoke it (whether a regular function or an arrow function without arguments being available to it). functionThatReturns2 can trivially be represented as

const functionThatReturns2 = ()=> 2.

It's no different than if it was represented as

 const functionThatReturns2 = (x)=> 2.

2 is consistently the result of first-class composition or the sole remaining non-functional value remaining in the pipeline demonstrated to complete it.

lightmare commented 2 years ago

educationally showing essentially what a task without regard to the previous/initial value is doing in a typical pipeline

This is irrelevant. Educational materials are not a valid sample for assessing which interpretation (intent or bug) of a piece of code is more likely to be correct. Regardless of how "foo" |> 2 is defined in the language (return 2 or throw TypeError), it will appear in educational materials to showcase the behaviour.

The question was: when I write "input" |> bar.foo and bar.foo is not a function, is it more likely that a) I wanted to discard "input" and return undefined, or that b) I mistyped bar.goo?

It is not "something unrelated".

2 is definitely not related to "foo"

You're opening the door to Heisenbugs, because if you mistype a function name in the middle of a pipeline, instead of getting a "cannot call undefined" error there, that undefined flows further down the pipe, where it may be treated as valid missing value replaced by some default, and you end up with mysterious behaviour, or an error in a distant location.

lozandier commented 2 years ago

educationally showing essentially what a task without regard to the previous/initial value is doing in a typical pipeline

This is irrelevant. Educational materials are not a valid sample for assessing which interpretation (intent or bug) of a piece of code is more likely to be correct. Regardless of how "foo" |> 2 is defined in the language (return 2 or throw TypeError), it will appear in educational materials to showcase the behaviour.

The question was: when I write "input" |> bar.foo and bar.foo is not a function, is it more likely that a) I wanted to discard "input" and return undefined, or that b) I mistyped bar.goo?

It is not "something unrelated".

2 is definitely not related to "foo"

You're opening the door to Heisenbugs, because if you mistype a function name in the middle of a pipeline, instead of getting a "cannot call undefined" error there, that undefined flows further down the pipe, where it may be treated as valid missing value replaced by some default, and you end up with mysterious behaviour, or an error in a distant location.

I may have misconstrued you with what you meant by "something unrelated"; "something unrelated" to me was referring to the final outcome of the pipeline I demonstrated. I thought you were dismissing it as being "something unrelated" or not intentional.

As far as your example, "input" |> bar.foo, it's conventionally understood that if it's not a function the value of bar.foo is returned with no other function for the result to be piped into.

bar.foo would only return undefined if it was a function that explicitly or implicitly returns undefined (implicitly if the function returns nothing which is unorthodox since functions in a pipeline conventionally return something to be fruitful functions) or the foo property didn't exist on the object bar at all.

If bar.foo is a function, it's passed "input" as it's sole parameter to become bar.foo("input") (typical valid syntax with a function belonging to an object). The result of that is result of the pipeline.

On that note, you didn't make clear what bar.goo is–a function? If bar.goo is a function, it's explicitly piped (or invoked with) "input" as its sole parameter. This all aligns with the typical behavior of explicit pipelining.

lightmare commented 2 years ago

Sorry for giving bad unclear examples. What I'm trying to convey is that your proposed semantics is extremely error-prone. Here a full example:

const bar = { goo(x) { return `boo ${x} hoo`; } };
"input" |> bar.foo |> console.log;

Your interpretation is that the above should print "undefined". Mine is that it should throw TypeError because I mistyped bar.goo.

lozandier commented 2 years ago

Sorry for giving bad unclear examples. Why I'm trying to convey is that your proposed semantics is extremely error-prone. Here a full example:

const bar = { goo(x) { return `boo ${x} hoo`; } };
"input" |> bar.foo |> console.log;

Your interpretation is that the above should print "undefined". Mine is that it should throw TypeError because I mistyped bar.goo.

I have to strongly disagree here. I would very much expect undefined to be passed to console.log.

undefined is a very valid value for both a unit of work to return and to be represented as a value to pipe to later functions. console.log(undefined) is very much valid code.

undefined is a result of your user error of mistyping bar.goo with bar.foo; it's not a function, so it's not piped "input". The value undefined then proceeds to be piped to console.log that is a function. The side-effect of console.log(undefined) is logging undefined on the console associated with the global head object in the JS runtime environment you're running the code against.

A typical pipeline continues until units of work are exhausted to pipe the previous or initial result; if the final task returns a function that is what is returned to complete the pipeline. Same goes for undefined and null which are also all valid parameters of functions after all.

lightmare commented 2 years ago

undefined is a very valid value for both a unit of work to return and to be represented as a value to pipe to later functions.

Yes, it's a perfectly valid value, but you're missing my point. There's definitely a typo/bug. If I wanted to ignore the "input" and print a completely unrelated value bar.foo (which may be undefined), I would've used a semicolon, not a pipe:

"input";
bar.foo |> console.log
lozandier commented 2 years ago

undefined is a very valid value for both a unit of work to return and to be represented as a value to pipe to later functions.

Yes, it's a perfectly valid value, but you're missing my point. There's definitely a typo/bug. If I wanted to ignore the "input" and print a completely unrelated value bar.foo (which may be undefined), I would've used a semicolon, not a pipe:

"input";
bar.foo |> console.log

What you're showing is that "input" was inconsequential just as much as (x) => y or "foo" |> 2, which is fine. There is no bug here with conventional pipelining semantics.

mikesherov commented 2 years ago

undefined is a result of your user error of mistyping bar.goo with bar.foo; it's not a function, so it's not piped "input". The value undefined then proceeds to be piped to console.log that is a function. The side-effect of console.log(undefined) is logging undefined on the console associated with the global head object in the JS runtime environment you're running the code against.

const bar = { goo() {} };
console.log(bar.foo('input'));

throws. So should:

const bar = { goo() {} };
'input' |> bar.foo |> console.log
lozandier commented 2 years ago

undefined is a result of your user error of mistyping bar.goo with bar.foo; it's not a function, so it's not piped "input". The value undefined then proceeds to be piped to console.log that is a function. The side-effect of console.log(undefined) is logging undefined on the console associated with the global head object in the JS runtime environment you're running the code against.

const bar = { goo() {} };
console.log(bar.foo('input'));

throws. So should:

const bar = { goo() {} };
'input' |> bar.foo |> console.log

It shouldn't since bar.foo isn't a function for "input" be attempted to be piped against it to cause bar.foo('input') to occur that would be an error as an expression. This is what allows"foo" |> 2, "foo" |> functionThatReturns2, "foo" |> functionThatReturns2(^), and "foo" |> functionThatReturns2() to all represent the same result of 2

|> should work against functions; if the value isn't a function it is piped to the next unit of work or returned to complete the function. I'm not totally dogmatic about this nonetheless

mikesherov commented 2 years ago

It occurs to me that if folks accept that hack-style pipelines can be relegated to |>>, them we don't actually need to resolve how topic placeholders behave at this time. In fact, we can land the unary pipeline proposal and defer partial application, topic placeholders, mixing... all to another time after we see how unary lands with the community.

The value of deciding that there's two proposals here and that unary claims |> is tantamount compared to figuring out in this moment how the two would successfully coexist.

mikesherov commented 2 years ago

It shouldn't since bar.foo isn't a function for "input" be attempted to be piped against it to cause console.log(bar.foo('input')).

even just bar.foo('input'); throws. It needs to throw undefined is not a function. We need to learn from Promises the dangers of just swallowing on things that are legitimate errors.

lozandier commented 2 years ago

It shouldn't since bar.foo isn't a function for "input" be attempted to be piped against it to cause console.log(bar.foo('input')).

even just bar.foo('input'); throws. It needs to throw undefined is not a function. We need to learn from Promises the dangers of just swallowing on things that are legitimate errors.

I already addressed this seconds before this comment with an edit; the first part of the sentence was explicitly communicating bar.foo('input') shouldn't be occurring before the edit nonetheless: bar.foo isn't a function so it's not invoked with "input" at all, so no error; bar.foo which is undefined is then piped to console.log.

I do see the value of explicit pipelining reacting to non-functional values the right of |> as merely the next value for the next function or the result of the pipeline if no other functions remain.

It's representative of typical pipelining leveraging fruitful functions towards a result with the functions executed from left to right sequentially with the output of an earlier task or initial value being passed to the following function till no functions remains. The last value is then returned.

 "foo" |> "bar" |> "baz" // result: "baz"; I would be fine not expecting an error here. 
 2 |> undefined |> null |> 6 |> "foo" // result: "foo"; I would be fine not expecting an error here. 
lightmare commented 2 years ago

We're still on different pages, so I'll try one more time to explain my perspective, and then hide my comments because we've gone way off topic.

Let's start with the unfortunate fact that I make typos. When I do, I appreciate error messages that point to the line of code where the typo lies. Given this mock object:

const bar = {
  work() { this.foo = 7; return 5; },
  goo(x) { return `boo ${x} hoo`; }
};
  1. correct code:

    bar.work() |> bar.goo |> console.log;

    Obviously I want to do bar.work(), then pass its output through bar.goo, then print the result.

  2. correct code:

    bar.work();
    bar.foo |> console.log;

    Obviously, I want to do bar.work(). Then after that's done, I want to print the value of bar.foo.

  3. problematic code:

    bar.work() |> bar.foo |> console.log;

    Here I definitely made a typo. There is zero chance that I wanted this code to do the same thing as example 2. It makes no sense for |> to act like ,

This should throw TypeError at the point where bar.foo is a non-function in a pipe, so that it points me to the typo.

Your suggestion, that this code should just silently ignore the result from bar.work(), and print the value of bar.foo (7), would just make my typos more frustrating — sometimes they'd produce perplexing output without throwing an error, other times they'd produce errors in another location in the code, far from the root cause.

You're making the operator more complicated and error-prone: sometimes it acts as a function call, sometimes as a comma.

lozandier commented 2 years ago

What you're demonstrating is similar to doing the following to omit multiple statements at all in a script perhaps:

someWindowTask() |> window.propertyThatMayOrMayNotExist |> console.log   

If window.propertyThatMayOrMayNotExist isn't a function, it's just piped to console.log; someWindowTask() may just have returned undefined (what a function returns at minimum) which may be inconsequential as it's passed to window.propertyThatMayOrMayNotExist as window.propertyThatMayOrMayNotExist(undefined)–or some other value representing it–if window.propertyThatMayOrMayNotExist was a function.

Either way, window.propertyThatMayOrMayNotExist outputs a result that is passed to console.log.

In your example, bar.work() will return at minimum undefined. undefined may be of value to bar.foo if it was a function, but it isn't. It's also undefined.

Either way, it returned a value that isn't a function that isn't unintuitive to just pipe to console.log to complete the pipe as console.log(undefined) which also returns undefined; this could be meaningful to be allowed since the piping paradigm is intended to return a value after a series of tasks/subroutine/functions.

If what's to the right of |> isn't a function, returning it or piping it to the next function isn't a novel idea.

It's not even that different w/ implicit piplining you can emulate within Unix: The outcome described isn't that different than mkdir foo && mkdir bar && cd $_ in Unix (using && and $_ to approximate |>> and (^)) where the value of mkdir foo is skipped for the result of mkdir bar instead that is then piped into cd to navigate to new folder bar while foo is created as a sibling folder.

All that said, agreed we're getting off-topic by an edge case.

lozandier commented 2 years ago

@lightmare Note–as written at the time of this comment–your second example

bar.work();
bar.foo |> console.log;

That code should resolve the second statement to be undefined |> console.log which should be the equivalent of console.log(undefined) that returns undefined following pipeline conventions.

It's more than reasonable to be no different than

bar.work() |> bar.foo |> console.log;

Which is the equivalent of

// bar.work() can return whatever value really
undefined |> undefined |> console.log

Which is conventional from a pipelining standpoint to return undefined to complete the pipe (what would be captured if assigned to a variable) with a side-effect of undefined being logged in a console. I recommend reviewing my previous comment related to this.

That said, it's again not a critical point/idea to my original proposal before I step away from such discussions for a while. I will also point out types (i.e. TypeScript) would be able to catch such a trivial error of attempting to use property foo from the object bar when it doesn't exist.

lightmare commented 2 years ago

I'd like to know what convention you keep talking about. I'm not aware of any language where piping a value into non-function silently swallows the value, and then feeds the non-function as input into the next step.

edit: I can't see where said "convention" comes from.

lozandier commented 2 years ago

@lightmare The comment I linked to in my previous response I think thoroughly addresses this. I'll also add what you're seeking as an invalid expression to cause an error can be had with implicit pipelining

bar.work() |>> bar.foo(^) |> console.log

bar.foo(^) will throw an error since it's an expression causing a TypeError (undefined caused by bar.foo isn't a function to have undefined from bar.work() passed into it).

ljharb commented 2 years ago

JavaScript isn’t TypeScript, and we should not be designing the former assuming the mitigations of the latter. Something that will most likely be hiding a bug is a dangerous thing to add.

lozandier commented 2 years ago

JavaScript isn’t TypeScript, and we should not be designing the former assuming the mitigations of the latter. Something that will most likely be hiding a bug is a dangerous thing to add.

I concur; I merely pointed out why a bug isn't being hidden with what was claimed as confusing with multiple counter examples of why what was raised was obfuscated to be more complicated than what it really is to further prove it isn't a bug and how it can be explained within the paradigm of typical pipelining.

When what's to the right of |> isn't a function, returning it or piping it to the next function is not a novel behavior with pipelining is what I explained. I've since then responded to the follow-up examples.

That said, I'm not entirely opposed to only allowing functions with |> and errors otherwise after the initial value if "hiding bugs" is an issue; the reasons why that might not be done I've explained in sufficient detail I think yet calling out explicit and implicit rehashes of "foo" |> 2 again and again.

lozandier commented 2 years ago

@lightmare Note what you're raising about pipelining associated with , is eccentric since pipelining is a tighter version of , (even the current readme mentions this) with functional composition in mind conventionally (see even my Bash example). If |>> is supposed to accept any valid RHS expression, a result of an expression not using ^ is effectively () => <value of expression>:

In general

initialValue |>> value |>>  task(^) 

can be expressed as

initialValue |>> (()=> value)(^) |>> task(^)

To |> this is merely

initialValue |> value |> task 

or

initialValue |> (()=> value) |> task

Even if |> invokes the implicit unary function value represents with the initialValue as the unary param, value is still returned in place of initialValue; accordingly it can make sense for a non-function value to be merely passed over to the next function–or returned as the final value for the pipeline if no more function (functions) remain.

The primary, ubiquitous purpose of a pipeline is to return a value in addition to chaining functions from left to right towards that end that may have side-effects unrelated to returning a value.

Since functions must at least return undefined to be fruitful; you can do

const result = taskThatReturnsUndefined() |>> 2 |>> console.log(^)

This pipeline demonstrated should return 2 to complete the pipeline and have a side-effect of console logging the value 2

With all this in mind, I think all of the following can be considered valid uses of |>> just as much as |> (all edge cases):

"foo" |>> 2 // 2 is returned 
"foo" |>> 2 |>> console.log(^) // 2 is logged
2 |>> undefined |>> console.log(^) // undefined is returned, undefined is logged to console
2 |>> 9 |>> undefined |>> "bar" // I would expect "bar" to be returned; I would not necessarily expect an error. 

This is all moot if |>> want to ban expressions without ^ and so on to better mirror this concern of "hidden bugs" associated with what was discussed before as edge cases to |>. But the value is there for idiomatic linearization/pipelining towards a single result towards it being argued weird to do either.