Readers on Readers
By Steven Leiva
I recently ran into a type error at work involving a monad transformer stack
with multiple Reader
monads and multiple environments. I have distilled the
problem to its essential components below:
#!/usr/bin/env stack
-- stack --resolver lts-18.2 exec ghci --package mtl
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Reader
firstReader :: MonadReader env m => Int -> SqlPersistT m ()
firstReader i = do
_ <- secondReader -- run a computation
env <- lift ask -- get the `env`
-- more work using the `env` environment
pure ()
secondReader :: MonadReader Int m => SqlPersistT m ()
secondReader = undefined
-- Note: This is a dummy type synonym, but it motivates the reason for the
-- multiple Reader monads.
type SqlPersistT = ReaderT Char
The idea here is that we are in a monad - SqlPersistT
- which can perform
database actions. That monad and that is stacked upon another monad that
requires its own environment.
If we run the script above, we will get a type error that boils down to env
does not match Int
. Let's try to figure out how we ended up with this type
error.
Unifying the Types
It is always good exercise to try to determine how GHC is arriving at a
particular type error. We can re-write firstReader
:
firstReader :: MonadReader env m => Int -> SqlPersistT m ()
firstReader i =
secondReader
>> (do
env <- lift ask
pure ()
)
And then, we can convert from infix notation to prefix:
firstReader :: MonadReader env m => Int -> SqlPersistT m ()
firstReader i = (>>)
secondReader
(do
env <- lift ask
pure ()
)
The lexical order of the expression now mirrors order in which expression are being applied. We can now proceed to the unification of the types.
One of the most important rules regarding type unification exercises is that we
want to apply an expression to a single argument at a time. This is simply
how Haskell works (remember, all functions are curried), and it has the
enormously beneficial consequence that we only have to think of two expressions
at a time. In our case, we want to apply >> :: m a -> m b -> m b
to
secondReader :: MonadReader Int m => SqlPersistT m ()
.
>> :: m a -> m b -> m b
secondReader :: MonadReader Int m => SqlPersistT m ()
Another important rule of type unfication exercises is that we want to use
unique type variables among the two expressions that we are comparing. For
example, because both >>
and secondReader
use the m
type variable name,
we will have to change one of them to something these:
>> :: m a -> m b -> m b
secondReader :: MonadReader Int m0 => SqlPersistT m0 ()
Now, we can proceed to match m a
to SqlPersistT m0 ()
. In other words, if
m a ~ SqlPersistT m0 ()
, that implies that m ~ SqlPersistT m0
and a ~ ()
.
We can remove the firt argument to >>
, and then replace the resulting type
signature with that information:
-- Remove the first argument in the type signature to `>>`
>> secondReader :: m b -> m b
-- Replace m ~ SqlPersistT m0
-- Replace a ~ ()
>> secondReader :: SqlPersistT m0 b -> SqlPersistT m0 b
-- Add constraints that we know about:
>> secondReader :: MonadReader Int m0 => SqlPersistT m0 b -> SqlPersistT m0 b
Now, let's apply >> secondReader
to the do
expression. Here, we can take a
shortcut to figure out the type of the do
expression. We know that pure () ~
SqlPersistT m ()
, and that that will be the type of the entire do
expression.
>> secondReader :: MonadReader Int m0 => SqlPersistT m0 b -> SqlPersistT m0 b
<do expression> :: MonadReader env m => SqlPersistT m ()
SqlPersistT m0 b ~ SqlPersistT m ()
implies that SqlPersistT ~ SqlPersistT
,
m0 ~ m
and b ~ ()
. Again, we can get rid of an argument to >>
secondReader
, and then replace the type variable left with the information we
know.
-- Remove the first argument to the type signature `>> secondReader`
>> secondReader <do expression> :: MonadReader Int m0 => SqlPersistT m0 b
-- Replace `m0` with `m`
-- Replace `b` with `()`
>> secondReader <do expression> :: MonadReader Int m => SqlPersistT m ()
-- Add constraints we know about:
>> secondReader <do expression> :: (MonadReader env m, MonadReader Int m) => SqlPersistT m ()
Now, we can clearly see our type error. The first constraint MonadReader env
m
disagres with MonadReader Int m
. Let's look at the head of the
MonadReader
type class declaration:
class Monad m => MonadReader r m | m -> r where
The first constraint is saying that the m
will resolve the r
to env
, and
the second constraint is saying that the m
will resolve the r
to Int
. Our
two constraints disagree with one another.
runReader
With that diversion into a type unfication exercise done, we can continue on how to define firstReader
in terms of secondReader
. firstReader
has an Int
in scope, which we want to use as the environment for secondReader
. We can do this by using runReaderT
to satisfy the MonadReader
constraint:
firstReader :: MonadReader env m => Int -> SqlPersistT m ()
firstReader i = do
backend <- ask
_ <- runReaderT (runReaderT secondReader backend) i
pure ()
Why does the above compile? Well, remember that secondReader
is a monad
transformer stack, where the first transformer is ReaderT
. We can "peel this
transformer off" using runReaderT
. Critically, the value that we are left
with has the type MonadReader Int env
, which we can also peel off using
runReaderT
!
Using mapReaderT
The above solution is perfectly valid, but there is a more idiomatic solution - mapReaderT
. As the documentation states, mapReaderT
allows us to "transform the computation inside a ReaderT". That's exactly what we are looking for!
firstReader :: MonadReader env m => Int -> SqlPersistT m ()
firstReader i = do
_ <- mapReaderT (\m -> runReaderT m i) secondReader
pure ()
mapReaderT
is giving us access to the computation inside of secondReader
,
which has the type MonadReader Int m => m ()
. Again, we use runReaderT
to
satisfy the MonadReader Int m
constraint!
Conclusion
Given my previous experience with these subjects, this post served to reinforce previously learned lessons.
In terms of type unification exercises, there is a simple algorithm that works for me:
- Re-write the expression into something that is lexically mirrors the order of application
- Do one application at a time
- Make sure that the type variables in a given application are unique between the two expressions
In terms of dealing with monadic expression:
- We can "peel off" the outter monadic structure by using the
runX
functions for that monad (unless, of course, if we are dealing withIO
).