Hugo's Blog

haskell

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

If we had some type b, in which we want to hide more constraints we are not allowed to do:

{-# 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 :)

last modified: May 29, 2023 at 13:07