What is MTL?
MTL is an acronym and stands for Monad Transformer Library. Its main purpose is to make it easier to work with nested monad transformers. It achieves this by encoding the effects of most common monad transformers as type classes.
The problem
Have you ever worked with two or more monad transformers nested inside each other?
If you haven't, working with what some call monad transformer stacks can be incredibly painful.
This is because, the more monad transformers you add to your stack the more type parameters the type system has to deal with and the worse type inference gets.
For example, here's a small example of a method that reads the current state in StateT
and raises an error using EitherT
:
import cats.data._
def checkState: EitherT[StateT[List, Int, *], Exception, String] = for {
currentState <- EitherT.liftF(StateT.get[List, Int])
result <- if (currentState > 10)
EitherT.leftT[StateT[List, Int, *], String](new Exception)
else
EitherT.rightT[StateT[List, Int, *], Exception]("All good")
} yield result
There's a bunch of type annotations and extra machinery that really has nothing to do with the actual program and this problem only gets worse as you add more expressions to your for-comprehension or add an additional monad transformer to the stack.
Thankfully, Cats-mtl is here to help.
How Cats-mtl helps
Monad Transformers encode some notion of effect
EitherT
encodes the effect of short-circuiting errors.
ReaderT
encodes the effect of reading a value from the environment.
StateT
encodes the effect of pure local mutable state.
All of these monad transformers encode their effects as data structures, but there's another way to achieve the same result: Type classes!
For example take ReaderT.ask
function, what would it look like if we used a type class here instead?
Well, Cats-mtl has an answer and it's called Ask
.
You can think of it as ReaderT
encoded as a type class:
trait Ask[F[_], E] {
val applicative: Applicative[F]
def ask: F[E]
}
At its core Ask
just encodes the fact that we can ask for a value from the environment, exactly like ReaderT
does.
Exactly like ReaderT
, it also includes another type parameter E
, that represents that environment.
If you're wondering why Ask
has an Applicative
field instead of just extending from Applicative
, that is to avoid implicit ambiguities that arise from having multiple subclasses of a given type (here Applicative
) in scope implicitly.
So in this case we favor composition over inheritance as otherwise, we could not e.g. use Monad
together with Ask
.
Ask
is an example for what is at the core of Cats-mtl.
Cats-mtl provides type classes for most common effects which let you choose what kind of effects you need without commiting to a specific monad transformer stack.
Ideally, you'd write all your code using only an abstract type constructor F[_]
with different type class constraints and then at the end run that code with a specific data type that is able to fulfill those constraints.
So without further ado, let's look at what the program from earlier looks when using Cats-mtl:
First, the original program again:
import cats.data._
def checkState: EitherT[StateT[List, Int, *], Exception, String] = for {
currentState <- EitherT.liftF(StateT.get[List, Int])
result <- if (currentState > 10)
EitherT.leftT[StateT[List, Int, *], String](new Exception("Too large"))
else
EitherT.rightT[StateT[List, Int, *], Exception]("All good")
} yield result
And now the mtl version:
import cats.MonadError
import cats.syntax.all._
import cats.mtl.Stateful
def checkState[F[_]](implicit S: Stateful[F, Int], E: MonadError[F, Exception]): F[String] = for {
currentState <- S.get
result <- if (currentState > 10) E.raiseError(new Exception("Too large"))
else E.pure("All good")
} yield result
We've reduced the boilerplate immensely! Now our small program actually looks just like control flow and we didn't need to annotate any types.
This is great so far, but checkState
now returns an abstract F[String]
.
We need some F
that has an instance of both Stateful
and MonadError
.
We know that EitherT
can have a MonadError
instance and StateT
can have a Stateful
instance, but how do we get both?
Fortunately, cats-mtl allows us to lift instances of mtl classes through our monad transformer stack.
That means, that e.g. EitherT[StateT[List, Int, *], Exception, A]
has both the MonadError
and Stateful
instances we need, neat!
So let's try turning our abstract F[String]
into an actual value:
val materializedProgram =
checkState[EitherT[StateT[List, Int, *], Exception, *]]
This process of turning a program defined by an abstract type constructor with additional type class constraints into an actual concrete data type is sometimes called interpreting or materializing a program. Usually we don't have to do this until the very end where we want to run the full application, so the only place we see actual monad transformers is at the very edge.
In summary Cats-mtl provides two things: MTL type classes representing effects and a way to lift instances of these classes through transformer stacks.
We suggest you start learning the MTL classes first and learn how lifting works later, as you don't need to understand lifting at all to enjoy all the benefits of Cats-mtl.
Available MTL classes
cats-mtl provides the following "MTL classes":
- Censor
If you're wondering why some of these are based on Monad
and others on Functor
or Applicative
,
it is because these are the smallest superclass dependencies practically possible with which you can define their laws.
This is in contrast to Haskell's mtl
library and earlier versions of Cats as well as Scalaz,
this gives us less restrictions over which type classes can be lifted over which transformers.
Because these type classes are commonly combined, the base typeclasses are ambiguous in implicit scope using
the typical cats subclass encoding via inheritance.
Thus in cats-mtl, the ambiguity is avoided by using the composition approach that we saw with Ask
earlier.
In a future version of Scala we might be able to avoid this and have the same type class encoding everywhere.