proposal-pipeline-operator icon indicating copy to clipboard operation
proposal-pipeline-operator copied to clipboard

Consider mentioning fluent API tree-shakability as a technical motivation

Open TzviPM opened this issue 1 year ago • 11 comments

In “ Implementation of a modular schema library in TypeScript with focus on bundle size and performance” (2003; see https://valibot.dev/thesis.pdf), Fabian Hiller presented valibot as an alternative to preexisting validation libraries that could achieve smaller bundle size by optimizing for tree shaking.

Many JavaScript apis such as RxJS, Valibot, Zod, JQuery, deal with the concept of pipelining. Existing approaches are to either:

  • expose a fluent API (ex: z.string().email().optional() ) which necessarily pulls in large bits of code (i.e. email validation) for smaller tasks (I.e. simple type assertion of a string).
  • use a pipe operator (ex. v.pipe(v.string(), v.email(), v.optional()) ). A Quick Look at npm or GitHub stats shows the popularity of a fluent API over a pipe function.

If support for pipelining were built into the language in a way that its syntax became preferred or even increased popularity of piped apis, it would benefit the ecosystem insofar as tree shakable code leads to smaller bundle sizes and allows libraries to provide more features without requiring consumers to ship unused code.

TzviPM avatar Dec 01 '24 17:12 TzviPM

💯

adrian-gierakowski avatar Dec 01 '24 18:12 adrian-gierakowski

fluent API:

z.string().email().optional('arg')

pipe function or method:

z.pipe(v.string(), v.email(), v.optional('arg'))

current hack pipe proposal:

z |> v.string(%) |> v.email(%) |> v.optional(%, 'arg')

Fluent API still looks the most clean. But considering its downside I'd still prefer pipe function over the current hack pipe syntax proposal...

Jopie64 avatar Dec 01 '24 23:12 Jopie64

Downside of pipe function is that it will always have a limit on the number of args if you want to have good typescript support

Being able to use point free style (coupled with curried, data last functions) in a hack pipe would be ideal

z |> v.string |> v.email |> v.optional('arg')

adrian-gierakowski avatar Dec 01 '24 23:12 adrian-gierakowski

Yeah, perhaps I misunderstood the proposal. I was thinking the syntax would be more like:

z |> v.string |> v.email |> v.optional('arg')

which would effectively desugar into:

((temp) => {
  temp = (v.string)(temp);
  temp = (v.email)(temp);
  temp = (v.optional('arg'))(temp);
  return temp;
})(z)

TzviPM avatar Dec 13 '24 15:12 TzviPM

Yeah, perhaps I misunderstood the proposal. I was thinking the syntax would be more like:

z |> v.string |> v.email |> v.optional('arg') which would effectively desugar into:

((temp) => { temp = (v.string)(temp); temp = (v.email)(temp); temp = (v.optional('arg'))(temp); return temp; })(z)

I think such:

((temp) => {
  temp = v.string(temp);
  temp = v.email(temp);
  temp = v.optional('arg', temp);
  return temp;
})(z)

because js doesn't have autocarry or easier

v.optional('arg', v.email(v.string(z)))

tools can do it now :)

snatvb avatar Dec 17 '24 13:12 snatvb

Yeah, perhaps I misunderstood the proposal. I was thinking the syntax would be more like:

z |> v.string |> v.email |> v.optional('arg') which would effectively desugar into:

((temp) => { temp = (v.string)(temp); temp = (v.email)(temp); temp = (v.optional('arg'))(temp); return temp; })(z)

I think such:

((temp) => {
  temp = v.string(temp);
  temp = v.email(temp);
  temp = v.optional('arg', temp);
  return temp;
})(z)

because js doesn't have autocarry or easier

v.optional('arg', v.email(v.string(z)))

tools can do it now :)

This piece seems very strange to me:

temp = v.optional('arg', temp);

What would the semantics be to turn a function into an invocation and an invocation into an invocation with an additional argument? It certainly doesn’t seem akin to anything else in the language, to my knowledge.

Also, in my example I added additional parentheses to emphasize the notion that each pipe may be a complex JS expression, and I imagine that the pipes should be evaluated lazily in the event of an error result.

I would especially be curious, in this version with appending an argument, how you would handle higher order functions. For example:

f |>  g(h)

Where g is something like:

g = (f) => (x) => f(f(x))

In this case, I would expect the result to be equivalent to h(h(f)), not g(h, f)

TzviPM avatar Dec 18 '24 00:12 TzviPM

+100. Making tree-shakeable APIs competitive with fluent APIs would be a very nice win for the JS ecosystem. @Jopie64 makes a good point that in its current state, it's really not as ergonomic as the fluent APIs... We would need something closer to:

z..string()..email()..optional('arg');
optional(email(string(z)), 'arg'); // desugared

ewinslow avatar Mar 27 '25 00:03 ewinslow

Or... dare I say it... operator .. desugars into F# semantics pipeline operator 😜

z..string()..email()..optional('arg');
optional('arg')(email()(string()(z))); // desugared

Jopie64 avatar Mar 27 '25 13:03 Jopie64

@ewinslow Yeah, that's Elixir-style pipelines, injecting the topic value into the following function as the first arg. JS does not have a tradition of structuring its function arguments in a compatible fashion, however (it's all over the place, with a wide variety of patterns), so that would only be useful for a fraction of existing code, and would pressure future libraries to write their functions to match. You'd still need a syntax to allow injection into arbitrary spots, and that probably means a bunch of temporary functions that just does argument rearranging, which is an anti-pattern we're trying to avoid.

@Jopie64 Exactly what I wrote in my previous paragraph, but swap out Elixir-style for F#-style. If anything, functions written in that curryable style are significantly less common on the web than functions written to have the first arg be the likely topic, so it would result in even more temporary functions.


The choice of which pipeline syntax/semantics to go with has been argued to death, and won't be relitigated.

tabatkins avatar Mar 27 '25 18:03 tabatkins

Thanks for the reply, @tabatkins. I had attempted to do some due diligence to read through the history and did see "Elixir" mentioned, but wasn't familiar with what that meant and missed the connection. My thought was to just sidestep the whole token issue and take a more opinionated stance to bless this particular function structure/pattern. But I understand you don't want to relitigate... I'll just go read the history deeper I guess. Thanks again.

In any case, still support the idea of mentioning "tree-shaking" as an important motivation for this proposal.

ewinslow avatar Mar 27 '25 20:03 ewinslow

Adding onto what @tabatkins said:

  • Elixir pipes or F# pipes like x |> fn0 |> fn1, along with partial functional application (PFA) and unary functional composition, come from point-free / tacit functional programming.

    • Many other people in TC39 (I am not one of them) are quite strongly opposed to encouraging point-free / tacit programming in JavaScript. This crucially includes several implementers of the major JavaScript engines.
    • These concerns about all point-free / tacit programming in JavaScript have been discussed multiple times over the past five years and are probably insurmountable for F# pipes or Elixir pipes.
    • There are more details in #221.
  • The purpose of the current Hack-style / topic-style pipes is not to be equally/more fluent than . method calls. It’s to be more fluent than any nested expression.

    A topic pipeline:

    z |> v.string(%) |> v.email(%) |> v.optional(%, 'arg') // Topic pipeline
    

    …would not be more fluent than:

    z.string().email().optional('arg') // Method calls
    

    …nor would the topic pipeline be more fluent than:

    z.pipe(v.string(), v.email(), v.optional('arg')) // Pipe function with onetime-use unary functions
    

    …but the topic pipeline would be more fluent than:

    v.optional(v.email(v.string(z)), 'arg') // Nested expressions
    

    …while avoiding the extra allocations/GC in the pipe function with onetime-use unary functions—allocations which, as I explained, the engines are concerned they would have difficulty optimizing. Topic pipelines are a zero-cost abstraction for slightly more verbose linear fluency.

    • The explainer’s real-world examples stresses that nested expressions are commonplace in real-world code, and their fluency would benefit from unwinding into topic pipelines. Their fluency would also benefit from pipe functions, F#/Elixir pipes, or other point-free / tacit programming—except that those would allocate many onetime-use unary functions.
  • The Committee in the past years has (understandably) become very stringent about any new syntax. For this reason, we have mostly been focusing on “lower-hanging fruit” proposals in TC39 or other standards venues, ones that don’t involve new syntax. See the new update in https://github.com/tc39/proposal-pipeline-operator/issues/232#issuecomment-2784879225.

Hopefully, that gives a little clarity to the historical constraints that this proposal (and its related proposals) are under and why things have turned out the way they are.


I do think there’s room for remarking in the explainer that any pipe operator would improve the fluency of consecutive calls to tree-shakeable, standalone functions. This is quite similar to the motivation described in #296. I figure we could close this issue and #296 once we add remarks about them to an explainer FAQ.

js-choi avatar Apr 08 '25 01:04 js-choi