Search code examples
kotlinfunctional-programmingarrow-kt

How to compose IO functions with other effects in Kotlin Arrow FX


We often need some request validation before handling it. With arrow v 0.8 a typical message handler looked like:

fun addToShoppingCart(request: AddToShoppingCartRequest): IO<Either<ShoppingCardError, ItemAddedEvent>> = fx {
    request
    .pipe (::validateShoppingCard)
    .flatMap { validatedRequest ->
        queryShoppingCart().bind().map { validatedRequest to it }         // fun queryShoppingCart(): IO<Either<DatabaseError, ShoppingCart>>
    }
    .flatMap { (validatedRequest, shoppingCart) ->
        maybeAddToShoppingCart(shoppingCart, validatedRequest)            // fun maybeAddToShoppingCart(...): Either<DomainError, ShoppingCart>
    }
    .flatMap { updatedShoppingCart ->
        storeShoppingCart(updatedShoppingCart).bind()                     // fun storeShoppingCart(ShoppingCart): IO<Either<DatabaseError, Unit>>
        .map {
            computeItemAddedEvent(updatedShoppingCart)
        }
    }
    .mapLeft(::computeShoppingCartError)
}

This seems to be a convenient and expressive definition of a workflow. I tried to define similar function in arrow v 0.10.5:

fun handleDownloadRequest(strUrl: String): IO<Either<BadUrl, MyObject>> = IO.fx {
    parseUrl(strUrl)                                                      // fun(String): Either<BadUrl,Url>
    .map {
        !effect{ downloadObject(it) }                                     // suspended fun downloadObject(Url): MyObject
    }
}

Which results in a compiler error "Suspension functions can be called only within coroutine body". The reason is both map and flatMap functions of Either and Option are not inline.

Indeed, the blog post about fx says

"Soon you will find that you cannot call suspend functions inside the functions declared for Either such as the ones mentioned above, and other fan favorites like map() and handleErrorWith(). For that you need a concurrency library!"

So the question is why is it so and what is the idiomatic way of such composition?


Solution

  • The idiomatic way is

    fun handleDownloadRequest(strUrl: String): IO<Either<BadUrl, MyObject>> =
        parseUrl(strUrl)
          .fold({
            IO.just(it.left())  // forward the error
          }, {
            IO { downloadObject(it) }
              .attempt() // get an Either<Throwable, MyObject>
              .map { it.mapLeft { /* Throwable to BadURL */ } } // fix the left side
          })
    

    Personally I wouldn't go to the deep end of IO with that one, and rewrite as a suspend function instead

    suspend fun handleDownloadRequest(strUrl: String): Either<BadUrl, MyObject> =
        parseUrl(strUrl)
          .fold(::Left) { // forward the error
            Either.catch({ /* Throwable to BadURL */ }) { downloadObject(it) }
          }
    

    What happened is, in 0.8.X the functions for Either used to be inlined. An unintended side-effect of this was that you could call suspend functions anywhere. While this is nice, it can lead to exceptions thrown (or jumping threads or deadlocks 🙈) in the middle of a map or a flatMap, which is terrible for correctness. It's a crutch.

    In 0.9 (or was it 10?) we removed that crutch and made it into something explicit in the API: Either.catch. We kept fold as inlined because it's the same as when, so there was no real correctness tradeoff there.

    So, the recommended thing is to use suspend everywhere and only reach for IO when trying to do threading, parallelism, cancellation, retries and scheduling, or anything really advanced.

    For basic use cases suspend and Either.catch is enough. To call into a suspend function at the edge of your program or where you need to bridge with these advanced behaviors then use IO.

    If you want to continue using Either you can define suspend/inline versions of regular functions at your own risk; or wait until IO<E, A> in 0.11 where you can use effectEither and effectMapEither.