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!

Previous
Previous

New Course Bundle!

Next
Next

An Alternative Approach