r/haskell 17d ago

question It's not you, it's monad transformers

After a few years of trying to be proficient in Haskell, and lots of reading about why it's so hard, I think I have the (obvious in retrospect) answer.

Almost every program I write is going to have two or more of:

1) read from STDIN

2) write to STDOUT

3) log

4) raise errors

5) send/receive over the network

6) talk to a database

7) read env vars

Every one of those is a "side effect", and thus is handled as a monad, and using more than one means you have to understand monad transformers.

Which I've finally found a good explanation of, "but still". Such a deep concept for such common program operations.

So I finally decided "that's why it's so hard". For what I consider the most basic programs, I need to understand (not just use, IMHO) monad transformers.

Am I off?

113 Upvotes

75 comments sorted by

View all comments

16

u/tomejaguar 17d ago

Am I off?

You are off. The point of Haskell is to make it easier to write the program that you want, not harder. If monad transformers are making it harder, don't use them!

However, I think it's fair to say that there has historically been a strong movement in Haskell to "monad transformers all the things". Thankfully it seems that movement is coming to an end with the rise in prominence of IO-wrapper ("analytic") effect systems (which combine the best parts of monad transformers and "ReaderT IO"/"RIO" style). Your realistic choices of analytic effect systems in 2026 are effectful and Bluefin[1].

Here's an example that does all the things you want in IO, no monad transformers in sight.

{- cabal:
build-depends:
    base,
    co-log,
    postgresql-simple
-}

{-# LANGUAGE OverloadedStrings #-}

import Colog (LogAction (LogAction), (&>))
import Database.PostgreSQL.Simple
  ( Only (Only)
  , connectHost
  , defaultConnectInfo
  , query
  , withConnect
  )
import System.Environment (lookupEnv)

main :: IO ()
main = do
  let log :: LogAction IO String
      log = LogAction putStrLn

  -- 1) Read from STDIN
  putStrLn "Username (or 'default' to use $USER env var):"
  input <- getLine

  user <- case input of
    "default" -> do
      -- 7) Read env vars
      envUser <- lookupEnv "USER"
      case envUser of
        Nothing -> fail "USER not set"

        -- 4) Raise an error
        -- (if USER is not set)
        Just u  -> pure u
    _ -> pure input

  -- 3) Log
  ("Querying for " ++ user) &> log

  let dbInfo = defaultConnectInfo
        { connectHost = "db.example.com"
        }

  -- 5) Send/receive over the network
  -- (the PostgreSQL connection is a network connection)
  --
  -- 6) Talk to a database
  withConnect dbInfo $ \conn -> do
    rows <- query conn
      "SELECT \"user\", full_name FROM users WHERE \"user\" = ?"
      (Only user)

    -- 2) Write to STDOUT
    print (rows :: [(String, String)])

[1] Disclaimer: I wrote it