This guide outlines an approach to overcome some problems with concrete monad stacks and monad composition.
So we're writing some code in IO, and everything is going great:
doStuff :: IO Int
doStuff = do
a <- someMonadIOAction1 x
b <- pure $ getInt
pure (a + b)
where someMonadIOAction1 :: Int -> IO Int
getInt :: Int
But then, the signature of getInt changes a little:
getInt :: Either String Int
We're encoding the possiblity for failure in our type signature now. Our do block will now look something like this:
doStuff :: IO Int
doStuff = do
a <- someMonadIOAction1 x
case getInt of
(Left _) -> pure 0
(Right b) -> pure (a + b)
Having to deal with the error at this point mixes up our program logic with our
error handling logic. In the QFPL applied course, we're taught we can write code
"for the happy path" using ExceptT
:
doStuff :: ExceptT String IO Int
doStuff = do
a <- someMonadIOAction1 x
b <- getInt
pure (a + b)
where someMonadIOAction1 :: Int -> ExceptT String IO Int
getInt :: ExceptT String IO Int
-- runExceptT :: ExceptT String IO a -> IO (Either String a)
main :: IO ()
main = do
eErrorValue <- runExceptT doStuff
case eErrorResult of
(Left err) -> show err
(Right result) -> show result
Writing our code in the ExceptT has allowed us to separate our program logic and
error handling. Program logic is in doStuff
and error handling in main
. It
should be noted that if doStuff
fails at any point in the do block, none of
the rest of the block is executed. This may not be desired behaviour but is
usually what we want. If you want to collect multiple errors, use something
like Validation.
The awkwardness of mixing Either and IO in the same do block extends to all other monads. A general example:
-- m stands for some specific monad.
doSomething1 :: m Int
doSomething2 :: m Int
doStuff :: m Int
doStuff = do
a <- doSomething1
b <- doSomething2
pure (a + b)
Everything is nice as long as we're operating in the monad m
. As soon as we
introduce some other monad n
:
doSomething1 :: m Int
doSomething2 :: m Int
doSomething3 :: n Int
doStuff :: m Int
doStuff = do
a <- doSomething1
b <- doSomething2
c <- _f $ doSomething3
pure (a + b)
We need some function in place of _f
to convert the n
into an m
. In the
case of IO
and Either
, the either
function or a case statement will
suffice. If we're using a monad transformer stack, we might use some sort of
lift in place of _f
.
Mixing monads m
and n
(and o
and ...) quickly gets awkward, but we can't
just have all our code operate within one monadic context. Or can we?
One of the solutions to this problem is to use the "MTL style". MTL came out of the recognition that although monads do not compose (in general), constraints do.
Rather than writing our functions in terms of some concrete monad (such as IO),
we write them in terms of some general monad m
and place constraints on that
m
so we can get what we need from the m
. That way we can operate within one
"monad" type and get nice syntax as a result.
mightFail :: ExceptT Error m Int
becomes
mightFail :: MonadError Error m => m Int
and
readsConfig :: ReaderT Config m Int
becomes
readsConfig' :: MonadReader Config m => m Int
and
readsConfigAndMightFail :: ReaderT Config (ExceptT Error Identity) Int
readsConfigAndMightFail = do
a <- readsConfig
b <- lift $ mightFail
pure (a + b)
becomes
readsConfigAndMightFail' :: (MonadReader Config m, MonadError Error m) => m Int
a <- readsConfig'
b <- mightFail'
pure (a + b)
Notice how we don't have to use lift in the second version? Although somewhat trivial in this example, it is a nice benefit.
In order to compose mightFail
and readsConfig
we had to use a monad
transformer stack. Whereas to compose mightFail'
and readConfig'
we only had
to put both constraints on the same function.
Also, readsConfigAndMightFail'
is strictly more general than
readsConfigAndMightFail
and is not contrained to any particular monad
transformer stack.
The lens library lets us take these ideas a little further with optics.
An environment can be read "MTL-style" using MonadReader
:
data Env = Env
{ _routeInfo :: RouteInfo
}
data RouteInfo = RouteInfo
{ _baseUrl :: URL
, _port :: Port
}
getBaseUrl :: MonadReader Env m => m URL
getBaseUrl = do
r <- ask
url <- r ^. routeInfo . baseUrl
pure url
Because Environments are often nested like this, it can be kind of annoying to access these nested structures. We can use classy lenses to create typeclasses that use lenses under the hood to make it easy to grab elements from the nested structure, as well as more clearly indicate what parts of the environment a particular function reads from:
{-# LANGUAGE TemplateHaskell #-}
data Env = Env
{ _routeInfo :: RouteInfo
}
makeClassy ''Env
data RouteInfo = RouteInfo
{ _baseUrl :: URL
, _port :: Port
}
makeClassy ''RouteInfo
getBaseUrl :: (MonadReader r m, HasRouteInfo r) => m URL
getBaseUrl = do
r <- ask
url <- r ^. baseUrl
pure url
Here we've removed the concrete type Env
from the first type parameter to
MonadReader and replaced it with the more general r
. However, any r
will not
do, so we've constrained the r
to r
's that "have route information". Accessing
the baseUrl is (a little) easier.
makeClassy ''RouteInfo
generates a typeclass HasRouteInfo r
that provides some
lenses for accessing the routeInfo fields from some r
.
We can do the same with errors, starting with our simple MonadError:
data URLError = EmptyURLError
| BadURL URL
getBaseUrl :: (MonadReader r m, HasRouteInfo r, MonadError URLError m) => m URL
getBaseUrl = do
r <- ask
url <- r ^. baseUrl
case url of
"" -> throwError EmptyURLError
u@"bad" -> throwError (BadURL u)
otherwise -> pure url
We can generalize MonadError
to take any error e
, but add a constraint that
says the error might be a URLError
:
data URLError = EmptyURLError
| BadURL URL
makeClassyPrisms ''URLError
getBaseUrl :: (MonadReader r m, HasRouteInfo r, MonadError e m, AsURLError e) => m URL
getBaseUrl = do
r <- ask
url <- r ^. baseUrl
case url of
-- # is just infix review
-- http://hackage.haskell.org/package/lens-4.17/docs/Control-Lens-Review.html#v:-35-
"" -> throwError (_EmptyURLError #)
u@"bad" -> throwError (_BadURL # u)
otherwise -> pure url
In this trivial case it doesn't really buy us anything other than a more awkward syntax, but it shows its usefulness when throwing and mixing multiple error types.
All of this code has happened in some general monad m
. At some point we need
to provide a concrete m
that satisfies all of the constraints we've composed
throughout our program.
Let's look at the most common typeclasses you'll be using and how to satisfy them:
An instance of MonadReader
exists for ReaderT
and the reader function
(->) r
(see here). Additionally, instances exist for a number of monad
transformers: ExceptT e m
, WriterT w m
, ..., provided that the m
in the
monad transformer has an instance of MonadReader
(it 'lifts' the MonadReader
instance from the inner m
- we'll look more at how this works later).
So to satisfy the MonadReader
constraint on a function, we have to provide a
ReaderT
or monad transformer stack with a ReaderT
in it:
muchRead :: MonadReader Config m => m ()
concrete :: ReaderT Config IO ()
concrete = muchRead
concrete' :: ExceptT SomeErrors (ReaderT Config IO) ()
concrete' = muchRead
For each of the "Has..." type constraints, you must provide an instance of "Has..." for whatever config you're reading. For example:
class HasConfig r where
config :: Lens' r Foo
muchRead :: (MonadReader r m, HasConfig r) => m ()
concrete :: ReaderT Config IO ()
concrete = muchRead
Won't work because we haven't provided an instance of HasConfig
for type
Config
. This however, will work:
class HasConfig r where
config :: Lens' r Foo
muchRead :: (MonadReader r m, HasConfig r) => m ()
concrete :: ReaderT Config IO ()
concrete = muchRead
instance HasConfig Config where
config = id
Note that you can use Template Haskell to write this boilerplate for you:
data Config = Config { fooX :: Int
, fooY :: Int
}
makeClassy ''Config
This will create the HasConfig
typeclass and an instance for Config
. See
here for details.
An instance of MonadError
exists for Either
, ExceptT
and any
transformer stack with a MonadError
instance for some m
in it's stack (see
here).
Usually, to satisfy the MonadError
constraint, we'll use the ExceptT
transformer:
muchError :: (MonadError SomeErrors m) => m ()
concrete :: ExceptT SomeErrors IO ()
concrete = muchError
concrete' :: ReaderT Config (ExceptT SomeErrors IO) ()
concrete' = muchError
For each of the "As..." error type constraints, you must provide instances for whatever error type you are using.
For example:
muchErrors :: (MonadError e m, AsURLError e, AsDBError e, AsNetworkError e) => m ()
-- The constraints above can be satisfied by:
concrete :: ExceptT AppError IO ()
concrete = muchErrors
-- Because...
-- 1. ExceptT satisfies the MonadError constraint.
-- 2. AppError is a data type with 'AsURLError', 'AsDBError', and
-- 'AsNetworkError' instances and so satisfies the 'e' constraints:
data AppError = AppURLError URLError
| AppDBError DBError
| AppNetworkError NetworkError
makeClassyPrisms ''AppError
instance AsURLError AppError where
_EmptyURLError = _AppURLError . _EmptyURLError
_BadURL = _AppURLError . _BadURL
instance AsDBError AppError where
-- ...
instance AsNetworkError AppError where
-- ...
Composing these constraints and satisfying them all is relatively straightforward from this point:
muchReadAndError :: ( MonadError e m
, AsURLError e
, AsDBError e
, AsNetworkError e
, MonadReader r m
, HasConfig r
)
=> m ()
-- The constraints above can be satisied by:
concrete :: ReaderT Config (ExceptT AppError IO) ()
concrete = muchReadAndError
-- Because...
-- 1. ReaderT satisfies the MonadReader constraint
-- 2. Config has an instance for HasConfig.
-- 3. ExceptT satisfies the MonadError constraint
-- 4. AppError has instances for AsURLError, AsDBError and AsNetworkError
[1] George Wilson, https://www.youtube.com/watch?v=GZPup5Iuaqw
[2] Carlo Hamalainen, https://carlo-hamalainen.net/2015/07/20/classy-mtl/
[3] Ben Kolera, https://github.com/benkolera/talk-stacking-your-monads/tree/master/code-classy
[4] Alexis King, https://lexi-lambda.github.io/blog/2018/02/10/an-opinionated-guide-to-haskell-in-2018/
[5] http://hackage.haskell.org/package/lens-4.17/docs/Control-Lens-TH.html#v:makeClassy