ramda / ramda

:ram: Practical functional Javascript
https://ramdajs.com
MIT License
23.77k stars 1.44k forks source link

Proposal to introduce either promises #1665

Open Risto-Stevcev opened 8 years ago

Risto-Stevcev commented 8 years ago

Since promises have an onRejected section, it would be nice to be able to chain a bunch of promises that return an Either type and have something like R.liftEitherP and R.pipeEitherP:

// Wraps a promise into an either type
let eitherOnFulfilled = (value) => Promise.resolve(S.Right(value))
let eitherOnRejected = (reason) => Promise.resolve(S.Left(reason))
R.liftEitherP = (promise) => promise.then(eitherOnFulfilled, eitherOnRejected)

The reasoning being is that you would have something like pipeEitherP which won't execute the next promise if it received a S.Left value, and you could do something like this:

let doChecks = body => S.Right(body).chain(condition1).chain(condition2)...
R.pipeEitherP(checkIfExists, update)(doChecks(body))
// Which might return something like:
// S.Left('Condition 1 failed')
// or
// S.Left('Resource does not exist')
// or
// S.Left('Update failed')
// or
// S.Right('Update successful')

I'm pretty new to ramda, so my apologies is this code isn't the best, but hopefully you get the idea behind what I'm trying to do. It's like pipeP except that it won't execute all promises if it failed halfway through, and the finishing promise will return an Either type.

Please let me know if there's a better way to do the above code.

arcseldon commented 8 years ago

@Risto-Stevcev - this is really interesting to me. Not a core member, so just sharing my views like you.

Perhaps you already pretty much know everything I am stating here but here's my take on state of things - Ramda has already dipped its toes in the shallow end with Promises (composeP, pipeP) so clearly there is interest to further support and integrate promises. LIkewise, implementations of Either such as sanctuary etc works well too, and I am actively interested in seeing their scope of use increased.

Am just starting to introduce Future implementations into production code at work (folktale / futurize), and this is the direction I am trying to promote personally because the execution is lazy. Makes for some elegant pipelining. For instance, it can achieve all the scenarios you point to above - although because you'd typically defer execution to the end where there would be less need for an Either. Found this screencast by Brian Lonsdale informative & it relates to the patterns you point to above (with extensions for Either if needed).

It will be interesting to see whether Ramda evolves towards embracing the algebraic data types further with baked in support, or whether the onus remains on the Client calling code to glue them together with Promises / Futures etc.

arcseldon commented 8 years ago

Have created the futurizer npm module to make Futures adoption easier. And there may be an expansion in this space shortly in terms of a larger project to unify individual efforts. Welcome ideas for patterns that use Ramda functions in conjunction with Futures to address concerns such as parallelism, serial order, possibly fork / join etc.

Some possibilities:

parallel processing

const app = liftN(makePage, getJSON('/posts', {id: 1}, getJSON('/tags', {id: 1})))

parallel processing, commuted to single Future result

traverse(Future.of, getPost, [{id: 1}, {id: 2}]);

control flow

const analyze = compose(chain(post('/analytics')), readFile)

stoeffel commented 8 years ago

link to the conversation about a new org for those who are interessted. https://github.com/stoeffel/futurize/issues/8

Risto-Stevcev commented 8 years ago

@arcseldon Nice. Futures are pretty cool. You mentioned that there would be less need for Either since it's deferred. How would you get similar functionality with futures?

arcseldon commented 8 years ago

@Risto-Stevcev - great question, will need to get some time to play around with the scenarios you have raised above via promises, and see how these would translate to futures. It might well be that we seek similar usage patterns - ie. chaining Either. The central difference I see is that Future uses standard algebraic type conventions so should "just work" with existing functor / monadic API (map, ap, chain etc), whereas with Promises you are going to have to come up with bespoke functions - eg. R.liftEitherP and R.pipeEitherP. Does that make sense, or am I wrong (quite possible) ?

Another thing I like, possibly the same as promises, is that we can commute different functors - for example we could take an array of Futures, and instead have one Future with an array of results - tricks like that allow us to use .fork() and have a single function handle the error case without needing to have applied Left() everywhere. This last point, I definitely need to work on some more scenarios to see if i am correct or missing something fundamental though.

Risto-Stevcev commented 8 years ago

@arcseldon Yeah, I definitely love that Future is an algebraic type, which definitely makes it less awkward to use than plain Promises. I might be wrong, but I still think that you would need to have something like R.liftEitherF to turn a regular future into one that either:

  1. Sees that the argument is S.Left and returns that without executing the Future.
  2. Sees that the argument is S.Right and applies the Future, which will itself return either S.Left or S.Right as a result.

From the ramda-fantasy example, I can see the chain signature is as follows:

:: Future e a ~> (a -> Future e b) -> Future e b

Which, from what I can see, says that whenever an error happens, that error message gets propagated through the chain, so that it will return the error at the end, which can you then parse using fork. This behavior is one part of what I'm trying to achieve.

But based on the signature alone, it's not clear whether the more important behavior that I'm trying to achieve actually happens. It looks like the pipeline doesn't stop executing when an error happens (I might be wrong though). For example, in the screencast you gave me, the guy defined a readFile Future like this:

var readFile = function(filename) {
  return new Future(rej, res) {
    fs.readFile(filename, 'utf-8', function(err, data) {
      err ? rej(err) : res(data);
    });
  });
};

Which matches the (a -> Future e b) signature for chain. So if I chained a future like Future.reject(2).chain(readFile), my understanding is that the error will get returned. But does readFile (and any others down the pipeline) get executed?

I haven't had the chance to try this. If it stops executing the pipeline, then I would argue that it would still be nice to have something to convert the end result into an Either type, rather than having to use fork, so that you can take advantage of the properties of Either (setoid, functor, applicable, monad) for the rest of your pipeline.

arcseldon commented 8 years ago

@Risto-Stevcev - I would encourage you read my updated README for futurizer. You ask a great question, and the answer is that any errors are propagated to the error handler on fork without calling any further functions in the chain pipeline. So the behaviour is sort of like Either, but baked in.

See the following:

Control Flow

What about control flow (running several Future returning functions in a pipeline) and error handling?

Well, chain can help here where the functions are Future returning.

Lets look at a couple of examples:

Successful chain

const R = require('ramda'),
  chain = R.chain,
  compose = R.compose,
  Task = require('data.task');

//+ findUser :: Number -> Future(User)
const findUser = id => {
  console.log(`findUser invoked with ${id}`);
  return Task.of({id: id, name: 'harry'});
};

//+ getFriends :: User -> Future([User])
const getFriends = user => {
  console.log(`getFriends invoked with ${JSON.stringify(user)}`);
  return Task.of([{id: 2, name: 'bob'}, {id: 3, name: 'tracy'}]);
};

//+ renderTemplate :: [User] -> Future(String)
const renderTemplate = users => {
  console.log(`renderTemplate invoked with ${JSON.stringify(users)}`);
  return Task.of(JSON.stringify(users));
};

And we can call these functions as follows:

 //+ friendPage :: Number -> Future(String)
const friendPage = compose(chain(renderTemplate), chain(getFriends), findUser);

const errFn = err => console.error(err);
const successFn = data => {
  console.log(data);
  expect(data).to.eql('[{"id":2,"name":"bob"},{"id":3,"name":"tracy"}]')
};

friendPage(1).fork(errFn, successFn);

//=> findUser invoked with 1
//=> getFriends invoked with {"id":1,"name":"harry"}
//=> renderTemplate invoked with [{"id":2,"name":"bob"},{"id":3,"name":"tracy"}]
//=> [{"id":2,"name":"bob"},{"id":3,"name":"tracy"}]

As you can see, first we called findUser, then passed the result of that into getFriends, and finally passed the result of that into renderTemplate to get our final result by forking our returned Future. And all of this happened inside Future returning functions!

What about errors?

In the above scenario, lets say we have an error in one of our functions as follows:

//+ getFriends :: User -> Future([User])
const getFriendsError = user => {
  console.log(`getFriends invoked with ${JSON.stringify(user)}`);
  return Task.of([]).rejected("Something went wrong");
};

Then we carry out the same calls as before:

//+ friendPage :: Number -> Future(String)
const friendPage = compose(chain(renderTemplate), chain(getFriendsError), findUser);

const errFn = err => {
  console.error(err);
  expect(err).to.eql('Something went wrong');
};

const successFn = data => {
  expect(false).to.eql(true);
  console.log(data);
};

friendPage(1).fork(errFn, successFn);

//=> findUser invoked with 1
//=> getFriends invoked with {"id":1,"name":"harry"}
//=> Something went wrong

Notice how the error gets propagated to the fork, and subsequent functions in the chain pipeline are not executed. Hope this helps.

Risto-Stevcev commented 8 years ago

@arcseldon Nice, so it works both ways. It looks like Futures are the right way to go. The only thing with fork is that sometimes you might not want to execute two separate functions, and your pipeline kind of stops there.

It would be nice to have something like this if you need to do some processing and then continue to execute other monads:

let unfollow = user =>
  getProfile.chain(getFriends).chain(findFriend(user)).chain(getId).chain(setProp('follow', false)).chain(updateProfile).chain(displayFlash)
// getId, setProp, updateProfile, and displayFlash call handle Either types which won't execute the logic if its a Left value, and displayFlash will display the error or success at the end
Risto-Stevcev commented 8 years ago

It might not be obvious from the example I posted why that would be useful, but here's a couple things I'm thinking about (there's probably more I can't think of right now):

  1. Using fork, rather than continuing the same chain that starts a few chains with Future monads and ends with Either monads (and maybe executes other Futures later), would introduce at least 2 more lines (and execution branches) to your code, which makes it less readable.
  2. All of the functions down the pipeline, getId, setProp, updateProfile, and displayFlash, are all safer because they will only execute if the the Either type is valid.

Worse (Future functions have a *):

findFriend*----fork--(error)---displayFlash
                 \---(success)---getId----setProp---updateProfile*---getPosts*---fork---(error)---displayFlash
                                                                                    \---(success)---paginate---render

Better:

findFriend---getId---setProp---getPosts---paginate---render---displayFlash
// non-futures ignore Left values (or try to recover them if appropriate), and displayFlash called only once
arcseldon commented 8 years ago

@Risto-Stevcev - you have total control over when to call fork.

So, if you have further pipeline chaining, just map, ap, chain, unnest etc over it all as you go along. Then when you are ready to release the escape capsule, invoke fork. Since no execution takes place until fork is called, you are basically just passing around a pointed functor (actually a monad) which is like a Container that behaves just like all the other data types.

There is no need to wrap it in yet another Either / Maybe but you could do that. Just bear in mind that whilst its trivial to flatten out nested data types of the same kind, it isn't quite so convenient when mixing nested data types.

So in your example above, you could accomplish the "better" route - just set up your composition pipeline according to needs, and wrap with chain / map and so on according to the type of the next input in the pipeline. It's taken me a while to gain a real intuition for this style of programming but it is becoming quite compulsive.

What I really like about all this is your asynchronous code finally starts to look very similar to its synchronous equivalent, and you can continue to code in a functional style with the same functional libs.

Risto-Stevcev commented 8 years ago

@arcseldon Maybe I'm misunderstanding fork, but it seems like you only have control over when to call fork within a pipeline of Futures, but what I have in my example is a chain containing a mixure of (a -> Future e b) and (a -> Either e b) types.

How would you call fork on displayFlash or render if they aren't returning Future types? They lack that function. And moreover, my example has two separate Future pipelines, and I don't think calling fork on the second one would execute the first one since they're disconnected by ---getId---setProp---.

Or are you saying to just make everything a Future and call fork whenever? If so, my objection to making everything a Future is that the construction for it, function(rej, res) is designed like that for async stuff, and using that for a function that just does computations (like getId and setProp) is a little awkard, wierd, and hard to read.

The better route would ideally look something like this in code:

P.pipeK(findFriend, getId, setProp, getPosts, paginate, render, displayFlash)
arcseldon commented 8 years ago

@Risto-Stevcev - Am going to have to see some code from you in order to explain further. Can you shell out some skeleton functions to illustrate exactly what you want and what the signatures of your functions are?

Definitely be keen to explore this further. Yes, I agree the fork construct is a shame... it does however benefit from adding an adapter layer between the async / promises signatures - reject / result & result / reject respectively. And that is the reason futurizer works seamlessly with them.

As you have already alluded, it would be interesting to somehow adapt fork so that it returns an Either - am starting to wonder about the possibilities of a new NPM module called Eitherizer !!! :smile:

Please provide some basic functions, even if empty body content, and I will pick this up. I simply don't know is the honest answer here, so lets work it out together.

arcseldon commented 8 years ago

Or are you saying to just make everything a Future and call fork whenever?

No, I am not saying that.

Risto-Stevcev commented 8 years ago

I ended up implementing what I was thinking of. I called it the LazyEither type. It's definitely nicer than Futures, because it pipelines and handles errors a lot better. You can see the full comparison in the examples section, but here's the basic gist:

Using Future:

let chain1 = path => readDir(path).chain(filterJs).chain(readFiles)
  , chain2 = R.pipe(R.map(numRequires), R.reduce(R.add, 0), R.concat('Total requires: '))

/** The Pyramid of Doom (a.k.a. callback hell) **/
chain1('.').fork(
  error => {
    console.error(`${error.name}: ${error.message}`)
  },
  data  => {
    R.pipe(chain2, writeFile('stats.txt'))(data).fork(
      error => {
        console.error(`${error.name}: ${error.message}`)
      },
      data => {
        console.log(data)
      }
    )
  }
)

Using LazyEither:

let getStats = R.pipeK(readDir, filterJs, readFiles, numRequires, printTotal, writeFile('stats.txt'))
getStats(LazyEither.Right('.')).value(val => console.log(val))
arcseldon commented 8 years ago

@Risto-Stevcev - wow, great work! We definitely have something to compare now :smile:

Please can you provide implementations for all the phantom functions you reference in your example above (readDir, filterJs etc). I would like to have full working code that is runnable. Please also include require / import statements :smile:

This looks really interesting.

Just seen this question on SOF - another happy customer using Futures :laughing:

davidchambers commented 8 years ago

I ended up implementing what I was thinking of. I called it the LazyEither type.

@Risto-Stevcev, are you interested in submitting a pull request to plaid/async-problem?

Risto-Stevcev commented 8 years ago

@davidchambers Sure! @arcseldon The examples are fully functional

arcseldon commented 8 years ago

@Risto-Stevcev - thank you for pointing out the examples folder.

Risto-Stevcev commented 8 years ago

Do notation

CrossEye commented 8 years ago

I'm trying to keep up here. With my whole family under the weather, I haven't been doing much outside work and keeping up with the horse farm. I'm quite interested in this discussion, even though the initial question on Promises didn't do much for me.

This seems to be going off in all sorts of interesting directions, and I can't wait to be able to chase them all down!

WaldoJeffers commented 6 years ago

Hello, are there any updates on this? RxJs is a great library for handling asynchronous stuff, but I much prefer Ramda's style (hopefully, I am not alone in this 😄).

CrossEye commented 6 years ago

No updates. Several months ago, we got semi-serious about getting a 1.0 version out the door, and this was to be included in those discussions. But then I got busy with a new job and dropped the ball. This is still slated to be included in those discussions, but I'm not sure when they'll be restarted.