adobe / ferrum

Features from the rust language in javascript: Provides Traits/Type classes & a hashing infrastructure and an advanced library for working with sequences/iterators in js
https://www.ferrumjs.org
Apache License 2.0
519 stars 25 forks source link

Infrastructure for metaprogramming in pipe/compose (await inside pipe()) #138

Open koraa opened 4 years ago

koraa commented 4 years ago

Provide a generalized pipe/compose metaprogramming infrastructure.

pipe.do = Tuple('do', 'fn'); // One element tuple as a tag
pipe.await = pipe.do((p, fn) => Promise.await(p));

Whenever pipe() (or compose) encounters do, it will evaluate all the functions to the left of the do statement and compose the functions to the right into a single function; passing the value and the functions into the function stored inside to.

compose(...leftFns, pipe.do(doFn), ...rightFunctions) <=> doFn(compose(...leftFns), compose(...rightFns))

We could also use a more general meta tuple that allows for generalized rewriting:

compose(...leftFns, pipe.meta(metaFn), ...rightFns) <=> metaFn(leftFns, rightFns)

In this framework do could be implemented as a special case of meta:

pipe.do = (doFn) =>
  pipe.meta((l, r) => (v) => doFn(composev(l)(v), composev(r)));

Do alone would allow for some interesting transformations on pipe; e.g. do(ifdef) would early abort pipe execution and do(map) would actually introduce loops as part of the function composition infrastructure.

Actually, I believe this would be about as general as the haskell do monad (hence the name) while staying in the fully functional framework.

This is different from the do syntax mostly because this uses explicit connectives instead of type dependent connectives as monads to (on the other hand this could be remedied with a type class).

Of course, how practical this is would have to be evaluated but the basic use case with await is in my definetly useful.

ptpaterson commented 3 years ago

I have a few first impressions. To start, I think that this is really clever and pretty cool. It sounds inevitable that if you use pipe often enough, then you will want to resolve a promise in there eventually.

question, does this pipe.await definition drop the right-side functions?

// it looks like this:
pipe.await = pipe.do((p, fn) => Promise.await(p));
compose(...leftFns, pipe.await(awaitFn), ...rightFunctions)

// would transform into this:
awaitFn(compose(...leftFns), compose(...rightFns))

// which is the same as:
Promise.await(compose(...leftFns))

When I discovered ferrum, and was asking about generic map/fmap operations, I was coming already very familiar with the fp-ts package. While using it, I had lamented that every Monad had to have it's own implementation of map, e.g. Either.map, Task.map, TaskEither.map, etc. I thought it would be cool if there could be a ferrum Functor trait, but of course learned that not even Rust does that.

Point is: I became familiar with using Task and TaskEither as wrappers for promises, as well as leveraging natural transformations with Either to handle a mixture of synchronous and asynchronous code. I would probably, personally, prefer to leverage those over pipe meta programming. I think that's really just saying that I would prefer to be more explicit (and indeed verbose) about the operation rather than use the do function. I think. It's likely I could be persuaded 🙂

// this
pipe(
  leftFns,
  asyncFn, // returns Task, not actual promise
  task.chain(anotherAsynFn),
  task.map(...rightFns)
)()

// or even

pipe(
  leftFns,
  asyncFn, // returns Task, not actual promise
  task.chain(anotherAsynFn)
)()
  .then(result => pipe(
    result,
    ...rightFns)
  )
)

// over this
pipe(
  leftFns,
  pipe.await(asyncFn),
  pipe.await(anotherAsynFn),
  ...rightFns)
)()
koraa commented 3 years ago

@ptpaterson

question, does this pipe.await definition drop the right-side functions?

No, using Promise.await directly it should transform:

compose(...rightFns)(Promise.await(compose(...leftFns)))

Point is: I became familiar with using Task and TaskEither as wrappers for promises, as well as leveraging natural transformations with Either to handle a mixture of synchronous and asynchronous code. I would probably, personally, prefer to leverage those over pipe meta programming. I think that's really just saying that I would prefer to be more explicit (and indeed verbose) about the operation rather than use the do function. I think. It's likely I could be persuaded slightly_smiling_face

Let me just point out that the syntax I was planning to introduce is slightly different from what you used in your comment; the following bit of could wouldn't really happen.

// over this
pipe(
  leftFns,
  pipe.await(asyncFn),
  pipe.await(anotherAsynFn),
  ...rightFns)
)()

Instead it would be this:

pipe(
  leftFns,
  asyncFn,
  pipe.await,
  anotherAsynFn,
  pipe.await,
  ...rightFns)
)()

I am not sure how the other code examples could be implemented; there isn't really a task variable anywhere that can be used. However, you could (and already can) easily use then as a free function:

const then = curry('then', (p, f) => Promise.resolve(p).then(f));

pipe(
  value,
  asyncFn,
  then(anotherAsyncFn),
  then(andAThirdAsyncFn));