In Haskell, I sometimes have IO-based actions that may produce failures. The IO aspect is usually incidental; because I need logging or metrics generation.
When not using a free monad or a fancy effect-system–just plain IO–I like to follow a mental model similar to Rust’s definition of error handling, which splits them into recoverable vs unrecoverable errors.
In Rust, when a function can fail with a recoverable error, it
returns a Result
, like:
Result<OutputType, ErrorType>
which the caller can pattern-match on. Let’s compare recoverable errors in Haskell.
Our code might look like:
do constraints <- constrainRenamed renamed
<- solveConstraints constraints
solved <- generaliseSolved solved
generalised resolveGeneralised generalised
And each of these steps may throw an exception. We leave it up to code above in the call chain to remember to catch them.
The trouble with exceptions is that they’re not mentioned in the type system. They’re also handled not at the call site, when for recoverable errors, that is usually the most straight-forward place to handle them.
It’s simply very easy–and it happens all the time–that you either throw the wrong exception type in the wrong place, or you forget to catch exceptions at the right place.
Verdict: Too dangerous.
A more direct approach, which is a bit like Rust, is to simply have
the function return Either ErrorType OutputType
and then
pattern match on the result to find out whether everything went
fine.
Now our code looks like this:
do constraints <- constrainRenamed renamed
case constraints of
Left err -> return (Left err)
Right constraints' -> do
<- solveConstraints constraints
solved case solved of
Left err -> return (Left err)
Right solved' -> do
<- generaliseSolved solved'
generalised case generalised of
Left err -> return (Left err)
Right generalised' -> do
resolveGeneralised generalised'
This is tedious to write, there’s all that repetition on the
Left
case. Reading it, you also can’t immediately tell
whether there’s any extra logic going on here.
Verdict: Too much code.
ExceptT
One way to solve this issue is to wrap up these actions in the
ExceptT
monad transformer. We have to make each of the
actions now live in the ExceptT
monad, so
IO (Either ErrorType OutputType)
becomes
ExceptT ErrorType IO OutputType
And our code goal is clean again:
runExceptTdo constraints <- constrainRenamed renamed
(<- solveConstraints constraints
solved <- generaliseSolved solved
generalised resolveGeneralised generalised)
The runExceptT
produces an
IO (Either ErrorType OutputType)
.
However, ExceptT
cannot
be an instance of MonadUnliftIO
– because it
necessarily requires multiple exit points. See this discussion
which should give you an idea of how hairy and unpredictable this can
be.
This is a big deal.
Essentially, if a monad is unliftio-able, then:
async
with UnliftIO.Async
and get predictable results.So, ExceptT
has to be thrown out too, sadly.
Verdict: Not compatible.
I previously explored in another post about
Try.do
, which I’ve decided against in favor of…
I have since written a
GHC compiler plugin to implement an alternative ?
-based
syntax for early return. I prefer that one than use of
Try.do
, because it doesn’t require any type magic or
special instances, and the ?
is more readable.