The problem with mtl
Published on
This article starts a series in which I am going to publish my thoughts on and experience with different «extensible effects» approaches. This one in particular will explain the problem with the classic mtl approach that motivates us to explore extensible effects in the first place.
How transformer stacks are born
Often we start with a single monad — perhaps Reader
or
State
. Then we realize it would be nice to add more to it —
other ReaderT
s or StateT
s, probably an
EitherT
etc.
At that point writing the whole stack in a type signature becomes rather onerous. So we create a type alias for it, or even a newtype, to improve type error messages. At first it looks like a good idea — we have «the monad» for our application. It removes a lot of the cognitive overhead — all our internal APIs are structured around this monad. The more time we spend working on our application, the more useful functions we invent that are automatically compatible and composable; the more joy it becomes to write code.
At least this is how I used to structure my code. I learned this
approach from xmonad, the first «serious» Haskell project I studied and
took part in. It has the X
monad, and all the functions
work in and/or with this monad.
Concrete stacks are too rigid
This approach breaks, however, once we want to have multiple applications based on the same code. At work, for instance, I’d like to reuse a significant part of code between the real application, the simulator (kind of a REPL for our messaging campaigns) and tests. But those necessarily use different monad stacks! The simulator doesn’t deal with MySQL and RabbitMQ connections; the server doesn’t need to be able to travel back and forth in time, like our simulator does; and tests for a piece of functionality should ideally use the smallest stack that’s necessary for that functionality.
So we should abstract in some way from the monad stack.
mtl’s classes
One such abstraction comes directly from mtl
, the monad
transformers library.
If we simply write
{-# LANGUAGE NoMonomorphismRestriction #-}
import Control.Monad.State
import Control.Monad.Reader
= do
foo <- ask
x $ fromEnum $ not x put
without supplying any type signature, then the inferred type will be
foo :: (MonadReader Bool m, MonadState Int m) => m ()
This type signature essentially says that foo
is a
monadic computation which has two effects: reading a boolean value and
reading/writing an integral value. These effects are handled by the
familiar «handlers» runState
and
runReader
.
We can combine any such computations together, and the type system will automaticaly figure out the total set of effects, in the form of class constraints. E.g. if we also have
bar :: (MonadState Int m, MonadWriter All m) => m ()
then
do foo; bar) :: (MonadReader Bool m, MonadState Int m, MonadWriter All m) => m () (
So it looks like mtl
can provide us with everything that
the «extensible effects» approach promises. Or does it?
The limitation
Unfortunately, if we write something a little bit different, namely
{-# LANGUAGE NoMonomorphismRestriction #-}
import Control.Monad.State
import Control.Monad.Reader
= do
foo <- get
x $ fromEnum $ not x put
where we’ve changed ask
to get
, the
compiler gets confused:
test.hs:6:3:
No instance for (Monad m) arising from a do statement
Possible fix:
add (Monad m) to the context of the inferred type of foo :: m ()
In a stmt of a 'do' block: x <- get
In the expression:
do { x <- get;
put $ fromEnum $ not x }
In an equation for ‘foo’:
foo
= do { x <- get;
put $ fromEnum $ not x }
test.hs:6:8:
No instance for (MonadState Bool m) arising from a use of ‘get’
In a stmt of a 'do' block: x <- get
In the expression:
do { x <- get;
put $ fromEnum $ not x }
In an equation for ‘foo’:
foo
= do { x <- get;
put $ fromEnum $ not x }
test.hs:7:3:
No instance for (MonadState Int m) arising from a use of ‘put’
In the expression: put
In a stmt of a 'do' block: put $ fromEnum $ not x
In the expression:
do { x <- get;
put $ fromEnum $ not x }
This is because mtl asserts, via a mechanism called functional
dependency, that a monadic stack can have only once instance of
MonadState
. Because get
and put
in the above example operate with different types of state, that code is
invalid.
Merging transformer layers
Since we can’t have multiple different MonadState
constraints for our reusable monadic computation, we need to merge all
StateT
layers in order to be able to access them through
the MonadState
class:
data MyState = MyState
_sInt :: Int
{ _sBool :: Bool
, }
Then we could generate lenses and put them in a class to achieve modularity:
class Has f t where
hasLens :: Lens t f
foo :: (MonadState s m, Has Int s, Has Bool s) => m ()
The drawbacks of this approach are:
- It is boilerplate-heavy, requiring an instance per field and a record per stack. When you need to convert between these records, it can be quite annoying.
- Since monad transformers don’t commute in general, you can’t always
merge two
StateT
layers together. For instance, there’s no way to achieve the semantics ofStateT s1 (MaybeT (StateT s2 Identity))
using only one layer ofStateT
.
Conclusion
mtl’s classes almost provide a valid «extensible effects»
implementation, if not for the functional dependency that lets us have
only single MonadState
instance per stack.
In the subsequent article we’ll explore ways to address this limitation.