Making your own Monad
There are many built-in monads from the MTL library that you'll find useful, like Reader, Writer, State, ExceptT, and so on. You can use transformers to combine these into a monad that is uniquely specific to your application. Today, we'll talk about how to make your monad into its own data type.
For our example monad, we'll incorporate an environment configuration using a Reader, a persistent application state, the ability to throw certain exceptions, and have all of that on top of IO. This would give us a monad like:
StateT AppState (ReaderT EnvConfig (ExceptT AppError IO)) a
And you might have many different functions in your application using this monad. Obviously you wouldn't want to keep writing down that long expression each time. So you could use a type alias, parameterized by the result type of the operation:
type AppMonad a = StateT AppState (ReaderT EnvConfig (ExceptT AppError IO)) a
loginUser :: AuthInfo -> AppMonad User
logoutUser :: AppMonad ()
printEnvironmentLogs :: AppMonad ()
However, an important trick to know is that we can make AppMonad
a proper type instead of a simple alias by using newtype
. To use this type as a monad, you'll need instances for the Monad
class and its ancestors. But we can derive those automatically, as long as we're using common MTL layers.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype AppMonad a = AppMonad
(StateT AppState
(ReaderT EnvConfig
(ExceptT AppError IO))) a
deriving (Functor, Applicative, Monad)
Notice we are still using a
as a type parameter for the result!
Like other monads, it is usually a good idea to use a "run" function to give users an entrypoint into this monad. This function will need appropriate inputs. First of all, it will take the monadic action itself. Then it will take initial values for the state and configuration.
There are a few different options for the result value. In certain cases, it can be the "pure" result type from the computation. But if IO
is on the monad stack, your result will need to be in the IO
monad. And in our case, since we've included ExceptT
, we'll also allow the result to be Either
, since it may encounter our error type.
runAppMonad :: AppMonad a -> EnvConfig -> AppState -> IO (Either AppError a)
How do we write such a function? The answer is that we'll incorporate the "run" functions of all the other monads on our stack! The key is knowing how to destructure a single monadic action. First, we pattern match our input action. Below, the expression stateAction
corresponds to a StateT
value, because that is the outer layer of our monad.
runAppMonad :: AppMonad a -> EnvConfig -> AppState -> IO (Either AppError a)
runAppMonad (AppMonad stateAction) envConfig appState = ...
How do we make progress with a stateAction
? By using a "run" function from the State
monad of course! In our case, we don't care about the final stateful value, so we'll use evalStateT
. This gives us an expression at the next level of the monad, which is a ReaderT
.
runAppMonad :: AppMonad a -> EnvConfig -> AppState -> IO (Either AppError a)
runAppMonad (AppMonad stateAction) envConfig appState = ...
where
readerAction :: ReaderT (ExceptT AppError IO) a
readerAction = evalStateT stateAction appState
Now we can do the same thing to unwind the Reader
action. We'll call runReaderT
using our supplied environment config. This gives us an action in the ExceptT
layer.
runAppMonad :: AppMonad a -> EnvConfig -> AppState -> IO (Either AppError a)
runAppMonad (AppMonad stateAction) envConfig appState = ...
where
readerAction :: ReaderT (ExceptT AppError IO) a
readerAction = evalStateT stateAction appState
exceptAction :: ExceptT AppError IO a
exceptAction = runReaderT readerAction envConfig
And finally, we use runExceptT
to unwind the ExceptT
layer, which gives us an IO action. This is our final result.
runAppMonad :: AppMonad a -> EnvConfig -> AppState -> IO (Either AppError a)
runAppMonad (AppMonad stateAction) envConfig appState = ioAction
where
readerAction :: ReaderT (ExceptT AppError IO) a
readerAction = evalStateT stateAction appState
exceptAction :: ExceptT AppError IO a
exceptAction = runReaderT readerAction envConfig
ioAction :: IO (Either AppError a)
ioAction = runExceptT exceptAction
Now we can use our monad in any location that has access to the initial state values and the IO monad!
There are a couple more tricks we can pull with our own monad. We can, for example, make this an instance of certain monadic type classes. This will make it easier to incorporate normal IO actions, or let us use State
actions without needing to lift
. Here are a couple examples:
instance MonadIO AppMonad where
liftIO = AppMonad . lift . lift . lift
instance MonadState AppState AppMonad where
get = AppMonad get
put = AppMonad . put
We could even create our own typeclass related to this monad! This idea is a bit more specialized. So if you want to learn more about this and other monad ideas, you should follow up and read our full Monads Series! You can also subscribe to our monthly newsletter so you can keep up to date with the latest Haskell news and offers!