I/O Error Handling Gracefully with Monad Transformer
The monad transformers is design to stacking monad to represent complex effects in Haskell. The transformer and mtl package provides a set of monad classes to help combining (stacking) multiple monads together. In real world applications, I/O is unavoidable, and its tricky to execute stacked monad in the IO monad.
Why bother?
Imaging we have a database query function which is actually a foreign function:
type Result = Int
type DBError = Int
foreign import ccall unsafe "query"
query :: Connection -> CString -> Ptr Result -> IO DBError
It results a status code where 0 means success, and non-zero means failure. Note
that this C function use the argument Ptr Result
to return the query result,
where the pointer can be allocated by functions like alloca
in Haskell.
We would like to capture the error and propogate it gracefully. We define a monad
to represent the effect:
newtype DBMonad a = DBMonad {runDBMonad :: ExceptT DBError IO a}
deriving (Functor, Applicative, Monad, MonadIO, MonadError DBError)
Then we could use the monad to wrap the foreign function:
queryDB :: Connection -> String -> Ptr Result -> DBMonad ()
queryDB conn sql r = do
code <- liftIO $ withCString sql $ \sql' -> query conn sql' r
when (code /= 0) $ throwError code
and use it like
work :: Connection -> DBMonad Result
work conn = do
ptr <- liftIO $ mallocBytes (sizeOf (undefined :: Result))
queryDB conn "SELECT * FROM users" ptr
liftIO $ peek ptr
entry :: Connection -> IO (Either DBError Result)
entry conn = runExceptT $ runDBMonad $ work conn
where runExceptT
returns a IO (Either DBError ())
value. Looks good, but want
if we want to use alloca
to allocate the pointer and ensure it been deallocated
when query finished? We would take a try:
work :: Connection -> DBMonad Result
work conn = do
liftIO $ alloca $ \ptr ->
queryDB conn "SELECT * FROM users" ptr
With no surprise, it won’t compile.
• Couldn't match expected type: IO Result
with actual type: DBMonad ()
• In the expression: queryDB conn "SELECT * FROM users" ptr
In the second argument of ‘($)’, namely
‘\ ptr -> queryDB conn "SELECT * FROM users" ptr’
In the second argument of ‘($)’, namely
‘alloca $ \ ptr -> queryDB conn "SELECT * FROM users" ptr’
Evaluate the monad in I/O
The reason is that alloca
is a function that expectes an IO action as its
argument, but queryDB
is a monadic action in DBMonad
. We need to lift
the monadic action to IO action, i.e., the reverse of liftIO
. Not hard
to implement:
runDB :: DBMonad a -> IO (Either DBError a)
runDB = runExceptT . runDBMonad
work :: Connection -> DBMonad Result
work conn = do
liftIO $ alloca $ \ptr ->
runDB $ do
queryDB conn "SELECT * FROM users" ptr
liftIO $ peek ptr
Now it continues complaining:
• Couldn't match type ‘Either DBError Result’ with ‘Int’
Expected: IO Result
Actual: IO (Either DBError Result)
• In the expression:
runDB
$ do queryDB conn "SELECT * FROM users" ptr
liftIO $ peek ptr
In the second argument of ‘($)’, namely
‘\ ptr
-> runDB
$ do queryDB conn "SELECT * FROM users" ptr
liftIO $ peek ptr’
In the second argument of ‘($)’, namely
‘alloca
$ \ ptr
-> runDB
$ do queryDB conn "SELECT * FROM users" ptr
liftIO $ peek ptr’
The alloca ...
blocks returns an Either DBError Result
value, while the
outer liftIO
and work
expects a value of type Result
. Fortunately, we
have liftEither
defined in the Control.Monad.Except
module:
liftEither :: MonadError e m => Either e a -> m a
Combining it with liftIO
, we get what we want:
liftIOEither :: IO (Either DBError a) -> DBMonad a
liftIOEither = liftEither <=< liftIO
Using it in work
:
work :: Connection -> DBMonad Result
work conn = do
- liftIO $ alloca $ \ptr ->
+ liftIOEither $ alloca $ \ptr ->
runDB $ do
queryDB conn "SELECT * FROM users" ptr
liftIO $ peek ptr
Everything works as expected.