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 ReaderTs or StateTs, 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

foo = do
  x <- ask
  put $ fromEnum $ not x

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

foo = do
  x <- get
  put $ fromEnum $ not x

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:

  1. 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.
  2. 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 of StateT s1 (MaybeT (StateT s2 Identity)) using only one layer of StateT.

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.