Who still uses ReaderT
Monad transformers are a pretty amazing skill in any haskellers tool set, and can be a pleasure to work with. However, after some experience digging around in the IHP codebase, which makes liberal use of the Implicit Parameters language extension, I've come to conclude that a monad stack with just a ReaderT can be simplified. This posts explores how.
Our running example: the ol' env
Consider we are making an application for querying a database. With this database comes a persistent connection that is required by the lower level functions. Because haskell lacks a global state, we will have to pass this connection down through the higher level abstractions as part of our environment, yuck:
-- We are just playing around with dummy types
data DbConn = DbConn
data Query = Query
-- and dummy functions
externalFun :: DbConn -> Query -> IO String
externalFun = undefined
makeConnection :: IO DbConn
makeConnection = undefined
getSelectAllQuery :: Query
getSelectAllQuery = undefined
-- Imagine that we would have many functions like this one
selectAll :: DbConn -> IO String
selectAll conn = let query = getSelectAllQuery in runQuery conn query
runQuery :: DbConn -> Query -> IO String
runQuery = externalFun
main :: IO ()
main = do
conn <- makeConnection
row <- selectAll conn
print row
Hiding DbConn with ReaderT
We want our functions to live in the IO monad with persistent access to some read only state. This is exactly what the ReaderT monad transformer was created for. We can use it to extend the IO monad and create our very own DBIO monad:
import Control.Monad.Trans.Reader
type DBIO a = ReaderT DbConn IO a
selectAll :: DBIO String
selectAll = let query = getSelectAllQuery in runQuery query
runQuery :: Query -> DBIO String
runQuery query = ask >>= \conn -> liftIO $ externalFun conn query
DBIO is special monad to can still perform IO operations using the liftIO function to lift it up into DBIO. In addition, it exposes the ask function, which returns the value of the environment; or in this case the DbConn instance. This allows us to easily write functions like selectAll, without having to think about passing down the DbConn, all the while preserving purity. Finally, when we want to execute some DBIO operation, we can unpack the ReaderT layer by providing the environment with which to resolve the inner expression, as seen in the main function.
This approach is already much better than the original solution. However, there is just one thing that bugs me and that is the need for wrapping IO operations with liftIO. Consider the following cousin of selectAll:
selectAllAndMore :: DBIO String
selectAllAndMore = do
liftIO $ sendMail "bossman@company.com" "I just ran a query bro"
liftIO $ setReminder 3600 "Check if the database is still running ma man"
selectAll
runQuery :: Query -> DBIO String
runQuery query = ask >>= \conn -> liftIO $ externalFun conn query
Ideally, I want to forget that I am not in a naked IO entirely. Luckily, we can.
Do you even lift bro? Nope!
Enter implicit parameters:
{-# LANGUAGE ImplicitParams #-}
selectAll :: (?conn :: DbConn) => IO String
selectAll = let query = getSelectAllQuery in runQuery query
runQuery :: (?conn :: DbConn) => Query -> IO String
runQuery query = externalFun ?conn query
main :: IO ()
main = do
conn <- makeConnection
row <- let ?conn = conn in selectAll
print row
selectAllAndMore :: IO String
selectAllAndMore = do
sendMail "bossman@company.com" "I just ran a query bro"
setReminder 3600 "Check if the database is still running ma man"
selectAll
The runQuery and selectAll functions now have a type constraint clause that say that the implicit variable ?conn should be in scope and is implicitly captured as well. This means that we can't call these functions if the typechecker is not convinced that ?conn is in scope. Using a simply let binding in main we can declare ?conn and call the functions we want. However, it seems we have exchanged one evil for another; Although we now may freely call IO functions without using a tedious liftIO, we constantly have to repeat ourselves elsewhere by adding the ?conn constraint to all our database functions. Let's see if we can resolve this.
Our goal is to create a new type (let's again pick DBIO) that encapsulates the implicit parameter type constraint. However, GHC won't allow this out of the box. It would mean that there are type variables that are not quantified in the most outer scope. Remember, haskell automatically does the following:
{-# LANGUAGE ScopedTypeVariables #-}
-- a is quantified on the most outer scope.
id :: forall a. a -> a
id x = x
{-# LANGUAGE ScopedTypeVariables #-}
-- this is also not type correct for other reasons
id :: forall a. (forall b. b) -> a
id x = x
Without first enabling the RankNTypes language extension. This is an extension that definitely belongs on the list of extensions that should never be enabled by default. It significantly complicates the job of the type checker, which results in unintelligible error messages when things go wrong. However, for our use case, everything remains quite sane, and thus we decide to cope.
{-# LANGUAGE RankNTypes #-}
type DBIO a = (?conn :: DbConn) => IO a
selectAll :: DBIO String
selectAll = let query = getSelectAllQuery in runQuery query
runQuery :: Query -> DBIO String
runQuery query = externalFun ?conn query
main :: IO ()
main = do
conn <- makeConnection
row <- let ?conn = conn in selectAll
print row
selectAllAndMore :: DBIO String
selectAllAndMore = do
sendMail "bossman@company.com" "I just ran a query bro"
setReminder 3600 "Check if the database is still running ma man"
selectAll
All our wishes have come true! Inside the DBIO monad we are allowed to perform arbitrary naked IO computations and we always have access to our connection with the ?conn variable. Although we can unlift DBIO back to IO with a simple let binding, we can create a final interface function, allowing the users of our codebase to be able to forget about the implicit parameters all together. In fact, if your peer programmers are avid ReaderT fans, they might not even notice anything different is going on from the outside.
{-# LANGUAGE RankNTypes #-}
runDBIO :: DbConn -> DBIO a -> IO a
runDBIO conn next = let ?conn = conn in next
main :: IO ()
main = do
conn <- makeConnection
row <- runDBIO conn selectAll
print row
-- or if you want to be fancy:
mainFancy :: IO ()
mainFancy = makeConnection >>= flip runDBIO selectAll >>= print
I thought that was pretty neat :)