Savanni D'Gerinel
Savanni D'Gerinel

Reputation: 2489

How cleanly weave an IO operation into a call stack between two functions which use a custom Monad?

I am practicing at getting consistent with my error handling, and I keep hoping to see the code that I've written start shrinking. But I built up a domain-meaningful persistence function, and the amount of code I had to write just to do monad handling and custom error handling is astounding.

I have in my application multiple functions which I would call primitives, but they can catch certain errors and will raise them my throwing a value of the DBError type. So, I've defined them like so:

data DBError = ConversionError ConvertError
         | SaveError String
         | OtherError String 
         deriving (Show, Eq)

instance Error DBError where
    noMsg = OtherError "No message found"
    strMsg s = OtherError s

type DBMonad = ErrorT DBError IO

selectWorkoutByID :: IConnection a => UUID -> a -> DBMonad (Maybe SetRepWorkout)
insertWorkout :: IConnection a => SetRepWorkout -> a -> DBMonad ()

At the level of the calling application, a Workout is a unique object persisted to the database, so the application only ever calls saveWorkout, which itself uses selectWorkoutByID, insertWorkout, and updateWorkout in the ways you would expect:

saveWorkout :: IConnection a => SetRepWorkout -> a -> DBMonad ()
saveWorkout workout conn =
    r <- liftIO $ withTransaction conn $ \conn -> runErrorT $ do
        w_res <- selectWorkoutByID (uuid workout) conn
        case w_res of
            Just w -> updateWorkout workout conn >> return ()
            Nothing -> insertWorkout workout conn >> return ()
    case r of
        Right _ -> return ()
        Left err -> throwError err

This is ugly. I have to run and unwrap a DBMonad, run that in the IO monad, lift the IO back up into the DBMonad, and then check the results and re-wrap the results in the DBMonad.

How can I do this with less, and easier to read, code?

I'm expecting that using my custom application monad to handle recoverable errors would help me to reduce the amount of code I have to write, but this is doing the opposite!

Here are some additional questions:

Upvotes: 2

Views: 147

Answers (1)

Savanni D&#39;Gerinel
Savanni D&#39;Gerinel

Reputation: 2489

After reviewing http://en.wikibooks.org/wiki/Haskell/Monad_transformers, which is the first document on Monad Transformers that really helped me understand them, I figured out a decent solution.

A new version of the saveWorkout function would look like this:

saveWorkout :: IConnection a => SetRepWorkout -> a -> DBMonad ()
saveWorkout workout conn =
    ErrorT $ liftIO $ withTransaction conn $ \conn -> runErrorT $ do
        w_res <- selectWorkoutByID (uuid workout) conn
        case w_res of
            Just w -> updateWorkout workout conn >> return ()
            Nothing -> insertWorkout workout conn >> return ()

The deal is this:

withTransaction is returning IO Either DBError (). liftIO has the type MonadIO m => IO a -> m a. ErrorT is the standard constructor for everything of the ErrorT monad, and I defined DBMonad to be of that monad. So, I am working with these types:

withTransaction conn $ <bunch of code> :: IO (Either DBError ())
liftIO :: MonadIO m => IO (Either DBError ()) -> m (Either DBError ())
ErrorT :: IO (Either DBError ()) -> ErrorT IO DBError ()

Ideally, since ErrorT/DBMonad are part of the MonadTrans class, I would use simply lift in order to lift IO (Either DBError ()) back up into the ErrorT monad, but at this time I cannot get it to actually type check correctly. This solution, however, still makes the code better by removing the redundent re-wrapping that I had before.

Upvotes: 1

Related Questions