How to compose async results?

Hi folks! After migrating all of my fetches and indexdb calls to promise<result<'a, exn>> it has become really annoying to unwrap and pattern match a chain of calls. Is there a better way to do monadic composition?

Something like

let fetchCart = getId >=> fetchCartById

I wrote an AsyncResult module with a Kleisli composition function but it looks really ugly when you have more than 2 functions. (I guess I could make a composeK3, composeK4 but I’d rather not)

module AsyncResult = {
  type t<'a, 'e> = promise<result<'a, 'e>>
  
  let composeK = (
    f: 'a => t<'b, 'e>,
    g: 'b => t<'c, 'e> 
  ): ('a => t<'c, 'e>) => async a => {
    let result = await f(a)
    switch result {
    | Ok(b) => await g(b)
    | Error(e) => Error(e)
    }
  }
}

Also since it’s a Result wrapped in a Promise, there’s no clean way to do map and flatMap. So I added them to AsyncResult, but it doesn’t feel quite right. There has to be a better way to do this right?

Here’s the full AsyncResult module

For now I think I’m not going to use the kcomp and just stick to flatMapping the AsyncResults, but I’d love to know if there’s a better way.

Thanks :smiley:

2 Likes

ReScript community usually steers towards using “simple” solutions and tries to avoid monadic composition and point free style most of the time. Having said that, your solution looks quite OK since composing promises and results is always a bit cumbersome.

I don’t see the use of composeK3 etc since you can just pipe the composition:

let okAdd7 = okAdd2->AR.composeK(okAdd3)->AR.composeK(okAdd2)

If you’re going to process the error case until the boundaries of your function, you can also very much decide to get rid of results, use exceptions instead and write things in a more direct style.
See playground example.

2 Likes

Not sure I’m completely answering the question here, but lately I’ve been using a pretty light weight pipeable pattern using polyvariants to accumulate errors. A superficial example:

type parsed = {content: string}

let parse = async s => {
  switch s {
  | "hello" => Ok({content: "hello"})
  | _ => Error(#InvalidInput)
  }
}

let transform = async v => {
  switch v {
  | {content: "hello" as s} => Ok(s)
  | _ => Error(#InvalidTransformTarget)
  }
}

let report = async v => {
  switch v {
  | "hello" =>
    Console.log("Yup!")
    Ok("hello")
  | _ => Error(#CannotReport)
  }
}

let consume = v => {
  switch v {
  | "hello" => Ok(#Consumed)
  | "skip" => Ok(#Skipped)
  | _ => Error(#CouldNotConsume)
  }
}

let okThenSync = async (p, fn): result<'v, [> ]> => {
  switch await p {
  | exception e => Error(#UnknownError(e))
  | Ok(v) => fn(v)
  | Error(e) => Error(e)
  }
}

let okThen = async (p, fn): result<'v, [> ]> => {
  switch await p {
  | exception e => Error(#UnknownError(e))
  | Ok(v) => await fn(v)
  | Error(e) => Error(e)
  }
}

let run = async () => {
  let res =
    parse("test")
    ->okThen(transform)
    ->okThen(report)
    ->okThenSync(consume)

  switch await res {
  | Ok(#Consumed) => Console.log("Consumed!")
  | Ok(#Skipped) => Console.log("Skipped this one")
  | Error(#InvalidInput) => Console.log("Invalid input")
  | Error(#InvalidTransformTarget) => Console.log("Invalid transform target")
  | Error(#CannotReport) => Console.log("Cannot report!")
  | Error(#CouldNotConsume) => Console.log("Could not consume content")
  | Error(#UnknownError(e)) => Console.error2("Exception!", e)
  }
}

Playground link.

It’s small and malleable enough that you can just turn it into whatever you need, add helpers as you go, etc.

EDIT: Added general error handling as well.

4 Likes

How would you catch anything unexpected in this setup?
Suppose you are calling some external function that returns a promise.
And somewhere along the way, a promise gets rejected.

I forgot to add that in my example. I updated it to handle general errors as well.

Essentially it’s just about adding an exception branch to each of the okThenX functions, and return a general error with the exception from that. That will wrap all the JS code in a try/catch to catch any unexpected errors and forward them to the result.

You could also add an exception branch yourself to the switch that handles the final result, and all of that code will be wrapped in a try/catch.

EDIT: This reply makes me feel like I’m an LLM… “I forgot to add blah. I updated the example to blah” :smile:.

2 Likes

Thanks this is interesting, added a custom exception to see how that play out, sample.

This sounds like a good candidate for either a helper library or a recipe on the docs website.

1 Like

Yeah. Quite a lot of possibilities!

Imho, the problem here has nothing to do with rescript, but rather the function coloring that happens with async in particular. The reason this doesn’t become as apparent in e.g. js or ts is because the typing is basically non-existent in both those languages (ts typing is broken).

The solution would be an async pipeline (specifically tailored for async function composition or chaining). It’s rather trivial, you could basically just replicate Result and replace or add async functions, such as mapAsync which does the await explicitly (or uses Promise.then, or whatever you like).

If there’s any criticism to direct toward rescript here, it’s likely that the stdlib is a bit lackluster in certain areas. compare Result/Optiion with equivalents in rust - rust provides a much better toolbox here. this is even worse for async functionality in rescript: the language provides async constructs on a syntax level (fairly recent tbf), but the stdlib is not up to par to support that. so you have to roll your own functional extensions here.

Check out GitHub - bloodyowl/rescript-future: Cancellable futures for ReScript

5 Likes

Thanks everybody!

I really liked @zth pattern and Futures look really interesting too!

I’ll test those solutions, but I already like them more than my composeK

1 Like