Under the guidance of Peter Perháč, we will look into monad transformers (MT); what they are, why and how to use them. Since Monad type class is not part of the Scala standard library, we will be leaning on abstractions and using data types from the Typelevel Cats library. The aim of this session is to get everyone acquainted with the idea of monad transformers (and with Cats).
We will explore different ways they can be used to make your code neater and help with everyday tasks. Look at using MTs in for-comprehensions and covering a couple of lifting techniques to achieve this. We'll also explore using MTs outside of for-comprehensions, getting familiar with methods like subflatMap, semiflatMap, etc.
Understanding monads and type classes is not a prerequisite for this session (though having a rough idea what they are will help).
Monad F knows how to flatten F[F[A]] into a F[A].
Monad G knows how to flatten G[G[A]] into a G[A].
Having a monad F and monad G, can we compose them?
No, not generically. Monads don't compose.
By composing monad F and monad G we won't magically get a monad that knows how to flatten F[G[F[G[A]]]] into an F[G[A]] because there's always an F or a G in the way. Monad F can't know what G will be (and vice-versa), so monads can't be composed generically.
However, in some special cases, certain monads can be composed in a specific way. This is where monad transformers come into play. We can't have MTs for all monads, but for some it's possible: e.g. OptionT, EitherT, ReaderT, WriterT.
Not all monads have a respective monad transformer.
In some cases, though it is possible to write specialised monads (yes, a monad transformer is in itself a monad) that offer the effects of both the base monad and the wrapping monad.
For example, the OptionT[F[_], A] monad transformer must implement all the functionality of the Option monad, and delegate to the underlying base monad (F).
package.scala- various plumbing
repositories.scala- a bunch of "repositories" with hard-coded data in the form of
Maps - the repositories provide a variety of return types to play with: i.e.
OptionT[F, T],Future[Option[T]],Future[T](andFuture[Unit])
- a bunch of "repositories" with hard-coded data in the form of
models.scala- a bunch of
case classes for demonstration
- a bunch of
Mainobject- scratch-sheet-like app for convenience. Main is the default app that runs on
sbt run
- scratch-sheet-like app for convenience. Main is the default app that runs on
HelloFutureOptions- demonstrates working with our repositories without cats and monad transformers.
- the
OptionTreturned from one of the repository methods is re-nested via thevalueproperty ofOptionT - this app shows how we could write apps if we're hell-bent on avoiding the convenience of libraries such as cats
HelloOptionT- demonstrates working with
OptionTmonad transformer. Lifting is done via utility methods onOptionTcompanion object:apply,fromOption,some,liftF - below the line are example usages of other methods available on
OptionT, such asflatMapF,semiflatMap,subflatMap
- demonstrates working with
HelloOptionT2- same as above, but lifting is done via an
implicit classthat introduces the thrush operator - below the line are example usages of other methods available on
OptionT, such asflatMapF,semiflatMap,subflatMap
- same as above, but lifting is done via an
HelloSemiflatMap- working with monad transformers outside of a for-comprehension. Linking a bunch of
flatMap,semiflatMap,subflatMap,flatMapF - an example usage of
flatTap(nothing to do with monad transformers, just wanted to show it) - pros - certain operations can be achieved without resorting to for-comprehensions, cons - most useful applications can't make use of this approach
- working with monad transformers outside of a for-comprehension. Linking a bunch of
HelloEitherT- Better error reporting with
EitherTmonad transformer. Slightly more complex type signatures, more explicit typing, more verbose lifting - below the line are examples of converting between
OptionTandEitherT
- Better error reporting with
- Tests
- testing the behaviour of the apps, making sure
HelloFutureOptions,HelloOptionT,HelloOptionT2, behave the same - demonstrate better error reporting achieved by using
EitherT
- testing the behaviour of the apps, making sure
- if it ends with T, it's likely a Monad Transformer (
OptionT,EitherT,ReaderT,WriterT, etc.) - whatever comes before the T, the MT will feel like. That means, if it's
OptionT, working with it should be almost no different to working with just a regularOptionmonad. You can do with anOptionTpractically all the things you would do with anOption, like.foldorgetOrElseororElse. There are easily discoverable convenience methods such asgetOrElseFandorElseFif your argument comes wrapped inF. TheFmonad is almost transparent when working with monad transformers. - when you no longer need a monad transformer, simply
foldit, orgetOrElseit orapplyit, etc. - Monad Transformer flattens (un-nests) nested monads, so instead of
F[G[A]]we can work withGT[F, A]. If you absolutely must re-nest the MT, the nested value is always readily accessible as thevalueproperty of the monad transformer.val fo = Future.successful(Some(42)); OptionT(fo).value === fo
| combinator | mapping function shape | ||
|---|---|---|---|
OptionT[F, A] |
map |
A => B |
OptionT[F, B] |
OptionT[F, A] |
flatMap |
A => OptionT[F, B] |
OptionT[F, B] |
OptionT[F, A] |
flatMapF |
A => F[Option[B]] |
OptionT[F, B] |
OptionT[F, A] |
semiflatMap |
A => F[B] |
OptionT[F, B] |
OptionT[F, A] |
subflatMap |
A => Option[B] |
OptionT[F, B] |
| Starting from | lift | example | long form (without MT) |
|---|---|---|---|
none[F, A] |
OptionT.none[Future, Int] |
Future.successful(Option.empty[Int]) |
|
A |
some[F] |
OptionT.some[Future](42) |
Future.successful(Some(42)) |
F[A] |
liftF |
OptionT.liftF(future42) |
future42.map(Option.apply) |
Option[A] |
fromOption[F] |
OptionT.fromOption[Future](opt42) |
Future.successful(opt42) |
Future[Option[A]] |
apply |
OptionT(futureOpt42) |
futureOpt42 |
apply,cond,fromEither,fromOption,fromOptionF,left,leftT,liftF,pure,right,rightT- converting from
OptionT:.toRight(A),.toLeft(B)
A note on EitherTs
EitherT[F[_], A, B] is invariant in all three type parameters.
If you are modeling potential error cases as a sealed trait E you'll find it awkward dealing with EitherT[F[_], E, B].
Although your error E1 extends E, there's no "is-a" relationship between EitherT[F[_], E1, B] and EitherT[F[_], E, B].
You'll find yourself constantly explicitly typing your more specialised errors as their super type E, or using helper methods that help the types align.
Whichever way you approach it, it ends up getting rather messy quickly.
The other problem with EitherTs is you end up with practically two channels of failure: the Either could be a Left, but also, the Future could be a failed future.
There's an alternative approach (and an interesting discussion below) in this blog post https://typelevel.org/blog/2018/08/25/http4s-error-handling-mtl.html
I've put together a working example of this approach here: https://github.com/PeterPerhac/errorhandling-with-optics-http4s
just for reference:
