Open Risto-Stevcev opened 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.
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:
const app = liftN(makePage, getJSON('/posts', {id: 1}, getJSON('/tags', {id: 1})))
traverse(Future.of, getPost, [{id: 1}, {id: 2}]);
const analyze = compose(chain(post('/analytics')), readFile)
link to the conversation about a new org for those who are interessted. https://github.com/stoeffel/futurize/issues/8
@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?
@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.
@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:
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.
@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:
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:
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!
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.
@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
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):
fork
, rather than continuing the same chain that starts a few chain
s with Future
monads and ends with Either
monads (and maybe executes other Future
s later), would introduce at least 2 more lines (and execution branches) to your code, which makes it less readable.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
@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.
@arcseldon Maybe I'm misunderstanding fork
, but it seems like you only have control over when to call fork within a pipeline of Future
s, 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)
@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.
Or are you saying to just make everything a Future and call fork whenever?
No, I am not saying that.
I ended up implementing what I was thinking of. I called it the LazyEither type. It's definitely nicer than Future
s, 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))
@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:
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?
@davidchambers Sure! @arcseldon The examples are fully functional
@Risto-Stevcev - thank you for pointing out the examples folder.
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!
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 😄).
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.
Since promises have an
onRejected
section, it would be nice to be able to chain a bunch of promises that return anEither
type and have something likeR.liftEitherP
andR.pipeEitherP
:The reasoning being is that you would have something like
pipeEitherP
which won't execute the next promise if it received aS.Left
value, and you could do something like this: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 anEither
type.Please let me know if there's a better way to do the above code.