Skip to content

Instantly share code, notes, and snippets.

@sevanspowell
Created November 20, 2018 00:09
Show Gist options
  • Save sevanspowell/fb11df8555ab89ef20a43d82e780f8e4 to your computer and use it in GitHub Desktop.
Save sevanspowell/fb11df8555ab89ef20a43d82e780f8e4 to your computer and use it in GitHub Desktop.
MTL guide

This guide outlines an approach to overcome some problems with concrete monad stacks and monad composition.

The problem

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?

Solution

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.

Taking it further with optics

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.

Making concrete

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:

MonadReader

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

"Has" constraints

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.

MonadError

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

"As" constraints

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
-- ...

Bag of constraints

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

References

[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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment