getify / monio

The most powerful IO monad implementation in JS, possibly in any language!
http://monio.run
MIT License
1.06k stars 58 forks source link

AsyncEither documentation #19

Open kee-oth opened 2 years ago

kee-oth commented 2 years ago

Hi!

I was curious if there is documentation for AsyncEither. I see it was recommended to use the IO monad* but I prefer the error handling of AsyncEither (and I like having a more specific tool). Also from the same discussion: it's mentioned that "The best way to think about AsyncEither is that it's like a Future monad", what exactly is the difference between an AsyncEither and a Future?

Thanks for your time!

getify commented 2 years ago

There's not a lot of documentation on any of the monads besides IO and IOx. That sort of reflects the bias of this library, which is that it wants to funnel you into using IO/IOx, where the other monads serve as side dishes rather than the main course.

what exactly is the difference between a AsyncEither and a Future

The differences are more implementation that conceptual, but...

  1. Most JS monad libraries implement their Future monad by exposing an interface where you provide callbacks. AsyncEither automatically produces a JS Promise, as I consider that the most ergonomic form of asynchrony that will appeal to the broadest section of JS devs. While JS Promises do have a callback oriented interface (then(..) and catch(..), they also are consumable via await. Unless a library went out of its way to make its Future be a "thennable", you wouldn't have the same await affordance as you get by AsyncEither directly embracing promises.
  2. For convenience, AsyncEither automatically transforms over JS promises, meaning that if any method provided (like to a chain(..) or map(..) call) returns a promise, this promise is automatically subsumed into the AsyncEither instance, deferring the rest of its behavior. You can usually chain Futures together, but I think it feels more manual to do so than you'll experience with AsyncEither.
  3. Most Future monads require you to explicitly transform your Future resolutions (failure or success) into an Either value (Left or Right), if you prefer to monadically operate as an Either. I just put the Either interface on top of Future, and that's what AsyncEither is. So you are already automatically in that monadic context.

I prefer the error handling of AsyncEither

I understand that.

I endeavored to provide a better, more ergonomic approach to that via IO's IO.doEither(..). Just like with AsyncEither, Inside a do-either block (just a JS generator function), any Either:Left encountered will automatically be treated like a JS exception, meaning you can either try..catch to capture it, or if you don't, the Either:Left will propagate out as a promise rejection. Moreover, any JS exception (manually created or accidental) will become an Either:Left, with the same catch/propagate behaviors. So you're getting all that nice asynchrony Future'ish handling with IO.doEither`.

IOW, that's basically like a better form of how the AsyncEither chain works, in that you get try..catch opportunities if you want them, or otherwise any uncaught exception will just become a promise rejection like you'd expect.

I like having a more specific tool

I appreciate that, and typically agree. But Monio is specifically designed to have IO (and IOx) be attractive as "uber" monads that compose all of the functionalities you need for you, instead of requiring you to do the compositions yourself. By offloading that composition to Monio, you get a ton of complexity dealt with for you, and that also means more opportunities for Monio to performance-optimize where your code might be the lesser-optimized equivalent by default.

I think it's simpler that with IO.doEither(..), you just express asynchrony as Promise<v> or Promise<Either>, which is a more natural transformation from most other async APIs you'll interact with in JS. If you don't use IO.doEither(..), you may end up paying an "conversion tax" where you have to cast every promise into an AsyncEither. You can do so, of course, but it may feel more tedious over time.

All that is to say, I encourage you to think about using IO, even if just doEither(..) with your chain-style code unwound inside it.

But there may very well be limited cases where you're doing some success/failure style asynchrony that is not particularly side-effect'y -- though I argue asynchrony is inherently a side-effect -- in which case AsyncEither might be the more appropriate specialty tool.

I just don't think it should be your generalized tool for asynchrony, as I think IO is likely the better (certainly more capable and optimized) option in most cases.

kee-oth commented 2 years ago

@getify Thanks so much for the thorough reply!

I'll try out both approaches in my project and see what ends up feeling better for my coding style.

...any JS exception (manually created or accidental) will become an Either:Left, with the same catch/propagate behaviors

One huge pain point regarding Promises is the mixing of exceptions and purposeful rejections. I'm mainly looking for a tool to separate those concepts out which it what attracted me to Futures in the first place (speaking specifically of the FlutureJS library).

Is there a particular strategy for handling that with IO?

I'm also not sure how I feel about do functionality. It feels like an imperative escape hatch. Is there any benefit to do over chaining?

getify commented 2 years ago

I'm also not sure how I feel about do functionality. It feels like an imperative escape hatch.

That's exactly what it is, and it's by design (even in Haskell!). It's a convenience and a compromise.

In particular, it is the motivating feature behind this whole library, because I feel it offers the bridge between the declarative (and often heavily chained/composed style) of FP and the more familiar (and more imperative) JS style. I'm trying to bridge that gap and get more JS developers into using the IO monad (and other monads, too), so do is central to that effort.

Is there any benefit to do over chaining?

Beyond simply being an attractive bridge that offers the imperative compromise that's still encapsulated inside the protections and benefits of an IO monad, there are a number of challenges that any chain-style API suffers -- that includes jQuery, JS Promises, RxJS, and virtually any other tool you can name with the fluent API style.

One such disadvantage, in particular: when you need to propagate more than one value from step to step in a chain, you don't have any surrounding scope in which to do so.

const getFirstName => () => IO(() => document.getElementById("first-name").value);
const getLastName => () => IO(() => document.getElementById("last-name").value);
const renderFullName => (first,last) => IO(() => document.getElementById("full-name").innerText = `${last}, ${first}`);

getFirstName()
.chain(firstName => getLastName())
.chain(lastName => (
   renderFullName( /* OOPS, firstName not in scope! */ , lastName )
))
.run();

So, you either have to nest chains to create a shared scope...

getFirstName()
.chain(firstName => (
   // ugh, nested chaining, ANTI-PATTERN!
   getLastName().chain(lastName => (
      renderFullName(firstName,lastName)
   ))
))
.run();

... or, worse, you have to artificially pack multiple values into some container (like an array or object), pass that from one step to the next, then unpack it. This sort of effort muddies up both the return values and the parameter definitions of all involved functions.

And with IO, in particular, since it's lazy, that's even harder to do. To avoid chaining, you may have jumped through such hoops with Promises -- and if you have, you know how much that sucks! -- but to do so in IO you may go about it like this:

getFirstName().map(v => [ v ])
.concat( getLastName().map(v => [ v ]) )
.chain( ([ firstName, lastName ]) => renderFullName(firstName,lastName) )
.run();

I mean, I think that is a cool usage of Concatable (concat(..)), but IMO it's a distraction that makes the code less readable. Compare those above options to this:

IO.do(function*(){
   var firstName = yield getFirstName();
   var lastName = yield getLastName();
   yield renderFullName(firstName,lastName);
})
.run();

IMO, that's just resoundingly better (clearer, more maintainable, more familiar) code. Opinions differ of course.

There are other imperative constructs which can be useful (and/or more performant) in certain scenarios, which having a do-block lets you perform. For example, sometimes you just want an if instead of wrapping something up in a Maybe and folding it. Or sometimes you'd rather write a single for-loop that consumes/correlates items from multiple sources rather than go to all the trouble (purely in expression chains of course) to zip/concat sources together and reduce/flatMap them, etc.

getify commented 2 years ago

...pain point... is the mixing of exceptions and purposeful rejections. I'm mainly looking for a tool to separate those concepts out which it what attracted me to Futures in the first place (speaking specifically of the FlutureJS library).

I know some people are bothered by the exception space being mixed between intentional and unintentional exceptions. I am not bothered by it, and in fact I prefer it. Whether I plan an exception case, or it happens by accident, I still treat it as my responsibility to gracefully handle it. I find that easier to do when the paths are consolidated than when I have multiple exception paths to juggle. I designed Monio from that perspective, but that's just my take.

I'd be curious how you saw Futures (and specifically Fluture) as addressing that? The implementations I examined for Future didn't seem to have separate channels for intentional vs unintentional exceptions, but perhaps I missed some details.


Putting on my FP'er hat for a moment, I think one reason Either is liked is because it makes defining intentional exceptions pretty explicit (and the handling of them). My guess would be, FP adherents would answer your question by saying a library should not marshal unintentional (JS run-time) exceptions into Either:Left's -- therefore you'd know if an exception was intentional or not if it was wrapped in an Either:Left or not.

If you used IO / IO.do(..) (but NOT IO.doEither(..)), you could accomplish that approach. Just make sure all your intentional exceptions are Either:Left values. The thing you lose is not being able to detect intentional exceptions via try..catch (in the do block) or via the promise catch() from run(). In such an approach, Either:Left looks like just a normal "success" value as far as IO is concerned, so then it's entirely up to you to intercept it at each step and treat it as special.

I think there's a lot of merit to IO even in that "mode". But I just happen to personally think there's even more capability to take advantage of if you consolidate the exception paths, and that's what I prefer.

kee-oth commented 2 years ago

Thank you again for the responses!

I appreciate the example illustrating the ergonomics of do over chaining. It's very helpful! And I absolutely agree with do syntax probably being a friendlier way to get traditional JS programmers to feel more inclined to try out this library.

The implementations I examined for Future didn't seem to have separate channels for intentional vs unintentional exceptions, but perhaps I missed some details.

The author of the Fluture has this as one of the main selling points for the library. He can explain it better than I can so here's a link to an article about it. It does boil down to preference but I've often found myself frustrated by the mixing of the two concepts.

https://dev.to/avaq/fluture-a-functional-alternative-to-promises-21b#branching-and-error-handling

getify commented 2 years ago

Thanks for the link... interesting stuff. I think the "railway oriented" concept is entirely compatible with both how IO and AsyncEither work (indeed, also the other Sum types like Maybe and Either). For non-IO types, the re-combine point (aka "natural transformation") is a fold(..), where you get the chance to return to the success path.

For the IO type(s), the re-combine point is a bit more manual: after the run() has completed (promise resolved), you just pick back up by starting another IO. I see the then(..) of an IO's promise as the equivalent of the promise-resolve-dependent fold(..) of AsyncEither, which is that the value hasn't fully escaped the IO if dealt with there, and can thus still be naturally transformed back into another IO chain and continued.

What's interesting/different there is this part mentioned toward the end of the article:

Fluture's rejection branch was designed to facilitate control flow, and as such, does not mix in thrown exceptions. This also means the rejection branch of a Future can be strictly typed and produces values of the type we expect.

When using Fluture - and functional programming methodologies in general - exceptions don't really have a place as constructs for control flow. Instead, the only good reason to throw an exception is if a developer did something wrong, usually a type error. Fluture, being functionally minded, will happily let those exceptions propagate.

The philosophy is that an exception means a bug, and a bug should affect the behaviour of our code as little as possible. In compiled languages, this classification of failure paths is much more obvious, with one happening during compile time, and the other at runtime.

That is a philosophical difference between Fluture and Monio, and I'm happy to own that. This mindset comes from more statically typed (and compiled) languages where run-time errors are almost impossible, and even if possible are exceedingly rare. I don't think JS developers (even TS developers) would characterize JS development that way.

However, practically speaking, in JS, I'm not sure quite what this in particular means... or rather, whether it represents a difference from Monio or not:

Fluture, being functionally minded, will happily let those exceptions propagate.

IO lets unexpected exceptions propagate, too, and (unless you were executing code with IO.doEither(..)) does no transformations or lifting of those exceptions.

But they're still mixed in with the exceptions you might have intentionally done like a throw somewhere. I suspect Fluture never expects someone to use an explicit throw statement in their code, but if you did, I suspect that this exception (intentional as it is) is "mixed in" with the third railway path with all the other unintentional exceptions. IOW, I don't see any evidence that they somehow detect if you did throw explicitly, and pull those back onto the "intentional exceptions" track.

If you think about it that way, IO + Either:Left (the way I described in my previous comment) seems pretty equivalent to their approach. As long as you never do throw but instead do yield or return of an Either:Left value, your intentional and unintentional exceptions will remain on separate railways.

What's not equivalent is that AsyncEither is more opinionated, in that the assumption is made that any exception should come out as an AsyncEither:Left (similar to how IO.doEither(..) will marshal all exceptions to Either:Left). I think it would actually be super strange for an Either type to have a third outcome, which is an uncaught JS exception -- in the form of a mixed promise-rejection channel, where sometimes you're seeing an Either:Left and sometimes you're seeing just a native Error instance.

If you choose to use a type like AsyncEither, its name says on the box: "hey, even if things are async, what you'll get is either a Left or Right, eventually." That seems to me to be pretty reasonable and expected type-wise design and behavior.

What it comes down to, I think, is:

I may be missing other details/nuances, but I think broadly that describes the choices I'm hearing from you, with respect to using Monio.

I can't make those choices for you, but I hope this conversation has been helpful to pick out the relevant points of decision. :)