Embracing ReaderT finally

Intended audience: programmers, esp those with functional-programming background. My last outing with the ReaderT monad was a minor disaster and I ended up writing a custom wrapper that got the job done. At the time, the monad-stacking behavior was new to me and I couldn't make sense of the code I was getting into. Recently, I added a global-config in my blog-generator project (vāk). A handful of things needed to be dynamic and set from the shell env, and these get set into the global config which then gets passed around the app in multiple places. I thought it was time for another outing with the ReaderT monad. vāk's codebase is simple, in the sense that almost all effectful functions are just Aff a, which get rolled into top-level ExceptT functions and the main function "runs" these top-level functions. At a high-level, this just meant that I had to retain the same behavior but only change the top-level function to be ReaderT wrapping around an ExceptT and the main function just runs runReaderT and runExceptT (which I can compose into a runAppM). But of course, a lot of Aff a functions were internally calling a getConfig function (which is the global config getter). So, the idea was make those functions which called getConfig use the ReaderT monad transformer's ask function and that means those functions became ReaderT instead of Aff. And thus began the conversion of all such functions. Some functions still remained Aff because either global config was passed to them as a parameter, or they did not use the global config at all. Composing an Aff and a ReaderT meant that I had to "lift" the Aff into ReaderT. The liftAff instance does not work though because it does not "handle" errors. So, I ended up rolling my own: liftAppM :: forall a. Aff a -> AppM a liftAppM aff = ReaderT \_ -> ExceptT $ try $ aff Now, the Aff is run, errors are "caught" via try, converted into an Either and then all of that is rolled into an ExceptT, wrapped around a ReaderT leading to my AppM: type AppM a = ReaderT Config (ExceptT Error Aff) a I liked how I was able to, with this liftAppM, gradually move functions around until everything was neatly converted. For instance, if this was the pre-ReaderT function: buildSite :: Config -> ExceptT Error Aff Unit buildSite config = ExceptT $ try $ do initializeStuff config posts

Apr 18, 2025 - 21:09
 0
Embracing ReaderT finally

Intended audience: programmers, esp those with functional-programming background.

My last outing with the ReaderT monad was a minor disaster and I ended up writing a custom wrapper that got the job done. At the time, the monad-stacking behavior was new to me and I couldn't make sense of the code I was getting into.

Recently, I added a global-config in my blog-generator project (vāk). A handful of things needed to be dynamic and set from the shell env, and these get set into the global config which then gets passed around the app in multiple places.

I thought it was time for another outing with the ReaderT monad.

vāk's codebase is simple, in the sense that almost all effectful functions are just Aff a, which get rolled into top-level ExceptT functions and the main function "runs" these top-level functions. At a high-level, this just meant that I had to retain the same behavior but only change the top-level function to be ReaderT wrapping around an ExceptT and the main function just runs runReaderT and runExceptT (which I can compose into a runAppM).

But of course, a lot of Aff a functions were internally calling a getConfig function (which is the global config getter). So, the idea was make those functions which called getConfig use the ReaderT monad transformer's ask function and that means those functions became ReaderT instead of Aff.

And thus began the conversion of all such functions.

Some functions still remained Aff because either global config was passed to them as a parameter, or they did not use the global config at all.

Composing an Aff and a ReaderT meant that I had to "lift" the Aff into ReaderT. The liftAff instance does not work though because it does not "handle" errors. So, I ended up rolling my own:

liftAppM :: forall a. Aff a -> AppM a
liftAppM aff = ReaderT \_ -> ExceptT $ try $ aff

Now, the Aff is run, errors are "caught" via try, converted into an Either and then all of that is rolled into an ExceptT, wrapped around a ReaderT leading to my AppM:

type AppM a = ReaderT Config (ExceptT Error Aff) a

I liked how I was able to, with this liftAppM, gradually move functions around until everything was neatly converted.

For instance, if this was the pre-ReaderT function:

buildSite :: Config -> ExceptT Error Aff Unit
buildSite config = ExceptT $ try $ do
    initializeStuff config
    posts <- processBlogPosts
    -- and about 15 more lines of function calls, each in the 'Aff' monad

I could do this:

buildSite :: AppM Unit
buildSite = do
    config <- ask
    liftAppM $ do
      initializeStuff config
      posts <- processBlogPosts
      -- rest

Then, I'll see that processBlogPosts calls for config internally, so I'd have to make it use ask and therefore, turn it into an AppM. This means I'd also move function above it (initializeStuff) into an AppM:

buildSite :: AppM Unit
buildSite = do
    config <- ask
    initializeStuff
    posts <- processBlogPosts
    liftAppM $ do
        -- rest of the stuff

This way, I could gradually move things out of the inner liftAppM and finally get rid of it altogether. Just the logging bits remained in a generic m monad and could be lifted into my AppM via liftAppM so:

buildSite :: AppM Unit
buildSite = do
  config <- ask
  liftAppM $ Logs.logInfo "Starting..."
  liftAppM $ createFolderIfNotPresent tmpFolder
  postsMetadata <- generatePostsHTML
  liftAppM $ Logs.logSuccess $ "Posts page generated."
  liftAppM $ Logs.logInfo $ "Generating archive page..."
  _ <- writeArchiveByYearPage postsMetadata
  liftAppM $ Logs.logSuccess $ "Archive page generated."
  liftAppM $ Logs.logInfo $ "Generating home page..."
  _ <- createHomePage postsMetadata
  liftAppM $ Logs.logSuccess $ "Home page generated."
  liftAppM $ Logs.logInfo $ "Copying 404.html..."
  _ <- liftEffect $ execSync ("cp " <> config.templateFolder <> "/404.html " <> tmpFolder) defaultExecSyncOptions
  liftAppM $ Logs.logSuccess $ "404.html copied."
  liftAppM $ Logs.logInfo "Copying images folder..."
  _ <- liftEffect $ execSync ("cp -r " <> "./images " <> tmpFolder) defaultExecSyncOptions
  liftAppM $ Logs.logSuccess $ "images folder copied."
  liftAppM $ Logs.logInfo "Copying js folder..."
  _ <- liftAppM $ liftEffect $ execSync ("cp -r " <> "./js " <> tmpFolder) defaultExecSyncOptions
  liftAppM $ Logs.logSuccess "js folder copied."
  liftAppM $ Logs.logInfo "Generating styles.css..."
  _ <- generateStyles
  liftAppM $ Logs.logSuccess "styles.css generated."
  liftAppM $ Logs.logInfo "Generating RSS feed..."
  _ <- Rss.generateRSSFeed (take 10 postsMetadata)
  liftAppM $ Logs.logSuccess "RSS feed generated."
  liftAppM $ Logs.logInfo $ "Copying " <> tmpFolder <> " to " <> config.outputFolder
  _ <- liftAppM $ createFolderIfNotPresent config.outputFolder
  _ <- liftAppM $ liftEffect $ execSync ("cp -r " <> tmpFolder <> "/* " <> config.outputFolder) defaultExecSyncOptions
  liftAppM $ Logs.logSuccess "Copied."
  liftAppM $ Logs.logInfo "Updating cache..."
  _ <- Cache.writeCacheData
  liftAppM $ Logs.logSuccess "Cached updated."

There are (monad-reader-transformer) lift instances for both Aff and ExceptT but both will fail to handle errors.

Here's the implementation for ExceptT that will get called:

instance monadTransExceptT :: MonadTrans (ExceptT e) where
  lift m = ExceptT do
    a <- m
    pure $ Right a

If the m action throws an error, the app just crashes. Typically, I'd like for it to be returned as a Left value (the way non-errors are getting returned as Right).

I am still not very clear about the mechanics here though; tests with native monad-reader-transformer instances of lift for both Aff and ExceptT seem to crash the app instead of allowing the user to handle them in a custom way:

test :: Effect Unit
test = launchAff_ $ do
  config <- getConfig
  res <- runAppM config $ do
    lift $ ExceptT $ do
      testAff
  liftEffect $ logShow res

testAff :: Aff (Either Error Unit)
testAff = do
  throwError $ error "test error"

Running test will crash the app.

Hence, the custom (and poorly-named) liftAppM.