Life after monads

As of March 2020, School of Haskell has been switched to read-only mode.

After mastering monads, the next challenge we usually face is combining two (or more) separate monads. In this tutorial I outline simple plumbing (when the monads to be fit together are not that different), and two mainstream approaches for general plumbing.

Simple plumbing

When the monads are similar in nature, we can convert one to the other in an ad-hoc, monad-specific way. This mostly happens when chaining computations, some of which run in Maybe, others in Either.

Having the following functions,

data MyError = Err Text | OtherErr

verboseFailing :: Int -> Either MyError Int
verboseFailing = ...

justFailing :: Int -> Maybe Int
justFailing = ...

we can compose them to either result a Maybe or an Either, depending on our intent. Usually it is a good idea to go with the latter, but let's see both.

import Control.Monad ((>=>))
import Control.Error.Util (note)

verboseComp :: Int -> Either MyError Int
verboseComp = verboseFailing >=> note OtherErr . justFailing

Here we take advantage of the note function from the errors package, but you could write it yourself as well. The other way around:

import Control.Error.Util (hush)

justFailingComp :: Int -> Maybe Int
justFailingComp = hush . verboseFailing >=> justFailing

This time the hush function was used to silence the error. The errors package contains a lot of combinators that for example let us go from Maybe to MaybeT easily. Check it out.

Plumbing Eithers with different error types.

Once we are talking about plumbing and Eithers - assume we have something like the following.

data LowlevelError = ...
data InputError = ...

validated :: Text -> Either InputError Int
validated = ...

someComp :: Int -> Either LowlevelError Int
someComp = ...

How do we combine these methods? An approach is to create a hierarchy of errors.

data AppError = ELowlevel LowlevelError | EInput InputError

app s = do
    a <- EInput `fmapL` validated s
    ELowlevel `fmapL` someComp a

The fmapL is again from errors, and maps on the left. Although we could make it nicer by introducing a flipped alias.

wrapError = flip fmapL

app s = do
    a <- validated s `wrapError` EInput
    someComp a `wrapError` ELowlevel

Combining wildly different monads

What happens when we leave the safe realm of Maybe and Either?

import Control.Monad.Trans.Reader
import Control.Monad.Trans.State

more :: Reader Int Int
more = ask >>= return . (+1)

bang :: State String Int
bang = modify (++"!") >> get >>= return . length

-- | How to combine more and bang?
morePlusBang = undefined

main = do
    -- We can of course use them separately.
    print $ runReader more 42
    print $ runState (bang >> bang) "?"

Let me outline two mainstream approaches for plumbing different monads.

Solution 1: Monadic interfaces

One solution is to program against monad interfaces from mtl, instead of actual instances from transformers.

{-# START_FILE Lib.hs #-}
{-# LANGUAGE FlexibleContexts #-}
module Lib where
-- Notice we just import the monad interfaces,
-- not specific implementations.
import Control.Monad.Reader.Class
import Control.Monad.State.Class

-- | The interface of 'm' is specified using a typeclass now.
-- 'm' can be any data type, if it at least conforms to the
-- 'MonadReader' interface (which generalizes the various 'Reader's).
more :: (MonadReader Int m) => m Int
more = ask >>= return . (+1)

bang :: (MonadState String m) => m Int
bang = modify (++"!") >> get >>= return . length

-- | We need to propagate the merged requirements of the
-- parts we use.
morePlusBang :: (MonadReader Int m, MonadState String m) => m Int
morePlusBang = do
    a <- more
    b <- bang
    return $ a + b

{-# START_FILE Main.hs #-}
import Control.Monad.Trans.RWS
import Lib

main = do
    -- The type signature just fixes the unused 'w' monoid in RWS.
    print (runRWS (bang >> morePlusBang) 42 "?"  :: (Int,String,[()]))

Here we have chosen RWS, the Reader-Writer-State monad, as it supports our requirements well.

In fact, you are free to choose any monad (stack) that conforms to the requirements. For example

main = do
    print $ runState (runReaderT (bang >> morePlusBang) 42) "?"

automatically infers the stack to be ReaderT Int (State String) a.

The pro is that now we can nicely compose the computations, as long as we are able to find a single stack that fits all our requirements.

A cons is that the typeclasses might be more costy runtime-wise, as the typeclass dictionaries need to be passed. Also, the signatures are a bit more heavyweight due to the added constraints. Latter can be somewhat mitigated by using the ConstraintKinds language extension, which lets us do aliases like type MyReqs m = (MonadState String m, MonadReader Int m).

Baking the stack

It is a reported usage, that after fleshing out the exact requirements for a given application stack, a specific stack is "baked" in.

First, a new monad is created, and all typeclass constraints are replaced by using the specific monad.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype App a = App { runApp :: ReaderT Int (State String) a }
    deriving (Monad, MonadState, MonadReader)

more :: App Int
more = App $ ask >>= return . (+1)

bang :: App Int
bang = App $ modify (++"!") >> get >>= return . length

morePlusBang :: App Int
morePlusBang = do
    a <- more
    b <- bang
    return $ a + b

The pro seems to be that this helps the compiler better inline? While a con is, that now all functions in the app have access to the full stack, while previously functions could only access the part they had business with (Reader for more, State for bang).

Actually if no new functionality is added, App could have been a simple type alias instead of a newtype. A newtype makes more sense if we also provide specialized operations on it, at least hiding the internals a bit.

Solution 2: Transformer stacks

If we are not too picky, can just implement the functions via monad transformers, and plumb them together explicitly when needed.

Note that in the code now we need to lift the inner monad to the outermost level. If our stack were multiple levels deep, we might need lift . lift and so on.

import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Reader
import Control.Monad.Trans.State

more :: (Monad m) => ReaderT Int m Int
more = ask >>= return . (+1)

bang :: (Monad m) => StateT String m Int
bang = modify (++"!") >> get >>= return . length

morePlusBang :: ReaderT Int (State String) Int
morePlusBang = do
    a <- more
    b <- lift bang
    return $ a + b

main = print $ runState (runReaderT morePlusBang 42) "?"

The pro is that it is straightforward, and most of the time we don't need to combine too many monads anyway. The cons is that we really need to have a good grasp of monad transformers to be able to lift at will. But the latter would be needed anyway. Also, our code is a bit less flexible, since altering the stack has effect on functions using it. For example, adding a new layer needs us to add some more lifting.

I suggest also taking a look at the mmorph package, which can be useful if we have to work with monad stacks not under our control: It lets us to swap (hoist) the inner monad layer in a transformer (among others).

Outro

Hopefully this post gives some pointers to you. In the real world, nothing is ultimately black and white, and you are encouraged to do your own contemplation about the various approaches.