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.
Let me point to things that contribute to that behavior:
.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 flatMap
s:
(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))