@brandonchinn178

Fun with OverloadedRecordDot: Logging

November 6, 2026

I like -XOverloadedRecordDot. There, I said it. It's a controversial extension, and can produce obscure errors, but the ergonomics of having type-namespaced operations instead of global-namespaced or qualified-module-namespaced operations is too compelling for me.

One way I've started using record dots more is with logging:

module MyApp.Logging (
  Logger (..),
  LogLevel (..),
  stdoutLogger,
) where

import Control.Monad.IO.Class (MonadIO)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Records (HasField (..))
import Prelude hiding (log)

newtype Logger m = Logger (LogLevel -> Text -> m ())

data LogLevel
  = LevelDebug
  | LevelInfo
  | LevelWarn
  | LevelError
  deriving (Show, Eq)

instance HasField "render" LogLevel Text where
  render = \case
    LevelDebug -> "DEBUG"
    LevelInfo -> "INFO"
    LevelWarn -> "WARN"
    LevelError -> "ERROR"

instance HasField "debug" (Logger m) (Text -> m ()) where
  getField (Logger log) = log LevelDebug
instance HasField "info" (Logger m) (Text -> m ()) where
  getField (Logger log) = log LevelInfo
instance HasField "warn" (Logger m) (Text -> m ()) where
  getField (Logger log) = log LevelWarn
instance HasField "error" (Logger m) (Text -> m ()) where
  getField (Logger log) = log LevelError

stdoutLogger :: MonadIO m => Logger m
stdoutLogger = Logger $ \level message -> do
  putStrLn $ "[" <> Text.unpack level.render <> "] " <> message

In user code:

myFunc :: Logger IO -> IO ()
myFunc logger = do
  logger.debug "this is a debug message"
  logger.info "this is an info message"

  -- Compare with a slightly more verbose:
  Logging.debug logger "this is a debug message"
  Logging.info logger "this is an info message"

  -- Or polluting the global namespace:
  logDebug logger "this is a debug message"
  logInfo logger "this is an info message"

Going further, let's say I want to embed Logger into a custom monad. I could do:

newtype MyMonad a = MyMonad (ReaderT (Logger IO) IO a)

getLogger :: MyMonad (Logger IO)
getLogger = ask
myFunc :: MyMonad ()
myFunc = do
  logger <- getLogger
  liftIO $ logger.debug "this is a debug message"
  liftIO $ logger.info "this is an info message"

But we could also define HasField instances on monadic values directly!

logger :: MyMonad (Logger IO)
logger = ask

instance HasField "debug" (MyMonad (Logger IO)) (Text -> MyMonad ()) where
  getField = getLogFunc (.debug)
instance HasField "info" (MyMonad (Logger IO)) (Text -> MyMonad ()) where
  getField = getLogFunc (.info)
instance HasField "warn" (MyMonad (Logger IO)) (Text -> MyMonad ()) where
  getField = getLogFunc (.warn)
instance HasField "error" (MyMonad (Logger IO)) (Text -> MyMonad ()) where
  getField = getLogFunc (.error)

getLogFunc :: (Logger IO -> Text -> IO a) -> MyMonad (Logger IO) -> Text -> MyMonad a
getLogFunc f getLogger message = do
  l <- getLogger
  liftIO $ f l message
myFunc :: MyMonad ()
myFunc = do
  logger.debug "this is a debug message"
  logger.info "this is an info message"