Search code examples
scalafunctional-programmingscala-catsmonad-transformers

Using monad transformers change expression result


I found a behavior of monad transformers that is not at all intuitive for me.

Taking following data as an example:

type F[X] = OptionT[Either[String, *], X]

val success: F[Int] = 1.pure[F]

val empty: F[Int] = ().raiseError[F, Int]

val failed = "Boom!".raiseError[Either[String, *], Int].liftTo[F]

And then executing a line:

(success, empty, failed).tupled.value // Right(None)

We still get a Right, but I'd expect to see Left("Boom!") instead, as Either is an outermost effect. But when order is slightly modified:

(success, failed, empty).tupled.value // Left(Boom!)

This yields an expected value. Another thing is when we take values out of monad transformers before tupled and apply them in initial order:

(success.value, empty.value, failed.value).tupled // Left(Boom!)

We get a value that seems to me intuitive, but is not consistent with a result from the first example.

Does anyone know why monad transformers behave in this way? I simply considered monad tranformers a convenient way of working with stacked monads, but this seems to add more depth, as whether I use them or not it might actually yield a different value.


Solution

  • Let me point to things that contribute to that behavior:

    • monad transformer provides a data type, that is “focusing” on monad inside, in a way allowing programmer to ignore the handling/plumbing related with “external” data type, yet combining the capabilities of both
    • .tupled is simply a syntax sugar over chained .ap/.zip calls, which in turn, have to be consistent with .flatMap

    Then, what happens in particular cases, becomes way more obvious if we write it as a sequence of flatMaps:

    • (success, empty, failed).tupled.value // Right(None) - empty short-circuits evaluation on whole stack (whole point of using OptionT!), so failed is not executed/taken into consideration
    • (success, failed, empty).tupled.value // Left(Boom!) - this time it’s failed, which short-circuits evaluation, on external type though
    • (success.value, empty.value, failed.value).tupled // Left(Boom!) - here all values are Either values, so it’s failed, which makes expression to “fail”

    This particular behavior - one effect in a way “overriding” or adding new semantic to the other, because of using transformers, is generally consider something to be careful with, because it shows how important order of stacking becomes - I learned about it on example of Writer[T] position in stack, when used for logging - it has to be in right position to not forget logs to be written in presence of e.g. error.

    Below is an example of such behavior:

    import cats._
    import cats.data._
    import cats.implicits._
    import cats.mtl._
    import cats.mtl.syntax.all._
    import cats.effect.IO
    import cats.effect.unsafe.implicits.global
    
    def print[A: Show](value: A): IO[Unit] = IO { println(value.show) }
    
    type Foo[A] = WriterT[EitherT[IO, String, _], List[String], A]
    
    def runFoo[A: Show](value: Foo[A]): Unit = {
      value.run.value.flatMap(print).unsafeRunSync()
    }
    
    type Bar[A] = EitherT[WriterT[IO, List[String], _], String, A]
    
    def runBar[A: Show](value: Bar[A]): Unit = {
      value.value.run.flatMap(print).unsafeRunSync()
    }
    
    def doSucceed[F[_]: Monad](
        value: Int
    )(using t: Tell[F, List[String]]): F[Int] = {
      for {
        _ <- t.tell(s"Got value ${value}" :: Nil)
        newValue = value + 1
        _ <- t.tell(s"computed: ${newValue}" :: Nil)
      } yield newValue
    }
    
    def doFail[F[_]](
        value: Int
    )(using t: Tell[F, List[String]], err: MonadError[F, String]): F[Int] = {
      for {
        _ <- t.tell(s"Got value ${value}" :: Nil)
        _ <- "Boo".raiseError[F, Int]
      } yield value
    }
    
    runFoo(doSucceed[Foo](42)) // prints Right((List(Got value 42, computed: 43),43))
    runBar(doSucceed[Bar](42)) // prints (List(Got value 42, computed: 43),Right(43))
    
    runFoo(doFail[Foo](42)) // prints Left(Boo)
    runBar(doFail[Bar](42)) // prints (List(Got value 42),Left(Boo))