Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Compose world-crossing async functions with bind

I have a sample railway pipeline that works well:

open FSharpPlus

let funA n =
    if n < 10 then Ok n
    else Error "not less than 10"

let funB n =
    if n < 5 then Ok (n, n * 2)
    else Error "not less than 5"

let funC n = // int -> Result<(int * int), string>
    n
    |> funA
    >>= funB // it works

But when I want to turn the funB to an async function, I got a compilation error. Logically it shouldn't be different. Same output/input... What's wrong?

What should be done to make it work?!

open FSharpPlus

let funA n =
    if n < 10 then Ok n
    else Error "not less than 10"

let funB n = async {
    if n < 5 then return Ok (n, n * 2)
    else return Error "not less than 5" }

let funC n = // int -> Async<Result<(int * int), string>>
    n
    |> funA
    >>= funB // compile error
like image 317
akhansari Avatar asked Mar 03 '23 16:03

akhansari


1 Answers

Same output/input... What's wrong?

No, they don't have the same output/input.

If you look at the type of (>>=) it's something like 'Monad<'T> -> ('T -> 'Monad<'U>) -> 'Monad<'U> which is a fake signature of a generic bind operation, overloaded for Monads in general. In your first example the Monad is Result<_,'TError>, so your first example could be re-written as:

let funC n = // int -> Result<(int * int), string>
    n
    |> funA
    |> Result.bind funB

The signature of Result.bind is ('T -> Result<'U,'TError>) -> Result<'T,'TError> -> Result<'U,'TError>. This makes sense, if you think about it. It's like applying the substitution Monad<_> with Result<_,'TError> and you have the arguments flipped that's why we use |>.

Then your functions are both int -> Result<_,'TError> so types match, and it makes sense (and it works).

Now, moving to your second code fragment, the function funB has a different signature, it has Async<Result<_,'TError>> so now types don't match. And it also makes sense, you can't use the bind implementation of Result for an Async.

So, what's the solution?

The easiest solution is don't use bind, at least not for 2 monads. You can "lift" your first function to Async and use async.Bind, with the generic >>= or a standard async workflow, but inside it you'll have to use a manual match to bind the results to the second function.

The other approach is more interesting, but also more complex to understand, it consists in using an abstraction called Monad Transformers:

open FSharpPlus.Data

let funC n = // int -> Result<(int * int), string>
    n
    |> (funA >> async.Return >> ResultT)
    >>= (funB >> ResultT)
    |> ResultT.run

So, what we do here is we "lift" the funA function to Async, then we wrap it in ResultT which is a monad transformer for Result, so it has a bind operation that takes care of binding on the outer monad as well, in our case Async.

Then we simply wrap funB into ResultT and at the very end of the function we unwrap from ResultT with Result.run.

For more examples on layered monads in F#, see these questions

There are other approaches, some libraries provide some "magic workflows" which uses ad-hoc overloading to combine monads with composed monads (aka layered monads), so you write less code, but it's not as easy to reason about the types, since the overloads don't follow any substitution rule, you'll have to look at the source code to understand what's happening.

Note: Coding like this is a good exercise, but in real life consider using exceptions as well to not overcomplicate the code.

like image 196
Gus Avatar answered Mar 10 '23 21:03

Gus