I wrote the post Try.do for recoverable errors in Haskell and someone has written a nice response post proposing that this pattern is actually dangerous. While reading it I wrote up some of my own notes to share in reply.
I’d like to preface with the fact that I’m grateful for discussion and criticism of this approach. I’d like to use this approach, but if someone finds genuine issues with it, then I’ll naturally have to look elsewhere.
The first point made, and I think this is a solid point, is that you
can’t re-use handy monadic functions like traverse
or
forever
with this syntax. That’s a downer.
However, I think it also follows naturally. If ExceptT
were easily a clean MonadUnliftIO
instance, then we would
have a clean interpretation of how such functions would behave under all
the circumstances that unliftio is applicable in, most importantly
threading and when IO appears in negative position (withX
).
As it happens, we don’t.
This also applies to free monads, by the way. When you run the monad, you still have to decide on an interpretation, figure out how things will interact, and the idea of doing so does not bring me confidence.
Indeed, I may want to decide on a case-by-case basis whether
traverse
should optimistically run all actions (even
concurrently; which I’m doing in my compiler), only at the end
checking all results, or whether it should fail hard on the
first failing action. Happily, the
pooledMapConcurrentlyN
function doesn’t have to know or
care about how I do error handling.
In conclusion, I don’t see this as a complete obvious loss, and in some way it’s also a gain. I think there might be some interesting ideas to explore in this area.
The second point, in my reading, was that with this syntax, a
programmer will get into the habit of lifting all actions that aren’t
returning Either E A
. The downside being that if you use a
function like timeout
, like this:
<- timeout (someAction :: IO (Either E A)) result
Then you’ll hit a type error,
Couldn't match type: Maybe (Either ErrorType Int)
• : Either e0 b0
withIn a stmt of a qualified 'do' block:
• <- timeout 1000 action1 result
So, being in a rush, they ignore the code and just add an
fmap pure
call to fix the error:
<- fmap pure (timeout 1000 action1) result
Now we’ve lost the error, it didn’t short-circuit where it should
have! Actually, we haven’t lose the error. In fact, we
haven’t finished writing the code at this point. Assuming we
have compiler warnings on, we’ll be told that
result
is unused.
:10:7: warning: [-Wunused-matches]
Main.hsDefined but not used: ‘result’
|
10 | result <- fmap pure (timeout 1000 action1)
| ^^^^^^
Furthermore, when we decide to use it, we’ll see that its type is
Maybe (Either ErrorType Int)
The error is still here, we have to do something with it. You can’t use it without having to deal with the error in some way. I think if the compiler tells you something and you ignore it, that’s your responsibility. It can’t save you from yourself.
This is the first instance of a running theme in the post that I’m replying to, which is that code that doesn’t use results is dangerous.
try
functionThe next case considered was try
, which has this
type:
try :: Exception e => IO a -> IO (Either e a)
And what would happen if you wrote:
<- try (someAction :: IO (Either E A)) result
So that the type would be:
<- try (someAction :: IO (Either E A)) :: IO (Either E (Either E A)) result
The author claims that you won’t get a type error if you do something with the result. Let’s study it:
try
will catch exceptions thrown by the GHC exception
mechanism.Exception
.Left
case
of the either.In this example:
E
an instance of Exception
. I don’t
think there would be a good reason to do that. But let’s
continue with this assumption.throw
call from
base
to throw your result type (again, why would
you?), then try
will catch it. So it wasn’t
lost.result
variable
stands here; it has to be used somewhere.But I believe that this example is contrived. The whole point of the
Try.do
system is to avoid using the base
exception system for our business logic. Why, then, would we implement
an instance of Exception
and then willingly
throw
our failure type? Finally, the code doesn’t lose any
errors. In fact, the double Either
would be enough to
indicate to a programmer that something is off here.
As an aside, an easy to avoid confusion here is to simply use a type
dedicated to this. Let’s call it Failure
:
data Failure err ok = Failed err | Ok ok
Now, it’s not possible to be confused about Either
as
some random data and Failure
which only ever is produced by
a failure. Many have argued that Either
itself is a form of
boolean
blindness. I used Either
in my post as an aid to
demonstrate a point; there’s no compelling reason otherwise to use
it.
The author, citing Alexis King, would have us believe that names are not type safety. I disagree; all types are type safety. The attempt to put type techniques into “safe” and “unsafe” categories only serves to eliminate nuance.
Additionally, the author believes that syntax is not type safety. I think syntax is the main driving force behind type safety, or so I feel, having written the type generation stage of various compilers.
The last point the author brings up seems to be more of a criticism
of any IO
action returning Either
, than the
special syntax I proposed in my post.
In base there are two functions which are notable in one crucial aspect, their discarding of values:
finally :: MonadUnliftIO m => m a -> m b -> m a
bracket :: MonadUnliftIO m => m a -> (a -> m b) -> (a -> m c) -> m c
finally
discards b
, and
bracket
discards b
.
The author writes that for,
`finally` someAction2 someAction1
would necessarily discard the result of someAction2
. I
think this is a good observation. Your result will be discarded,
never to be seen again.
This does bring up a wider point, however: the type of bracket and
friends is a problem. Why don’t they use ()
? These
functions can swallow results all the time, regardless of whether you’re
using Try.do
or writing normal Haskell. In Scala, Kotlin
and Ceylon, bracket
uses void
or
unit
.
I’m glad the author brought this up, because we don’t currently have
any tooling to find and catch these mistakes. Essentially,
bracket
/finally
are risky functions to use due
to this.1 This is what prompted the title of
my reply – is it Try.do
that’s dangerous, or is it
bracket
/finally
?
Is it a criticism of Try.do
? I’m doubtful at the
moment.
I don’t think there’s any reason to call this technique “dangerous”.
I think that the examples in the post were slightly contrived (such as
the dubious suggestion that you would implement Exception
for your result type, or not using the result of an action that GHC
would warn you about), and in the last point it seemed to be arguing
against base
rather than my proposed technique, to which I
can only agree.
I’m unsure about the tone in the penultimate paragraph2, but overall I liked the criticism, it got me thinking. I do think there’s a wider discussion to be had about “use”, and perhaps my slow absorption of Rust thinking has me wishing there was less of a complacent attitude to throwing away results in Haskell’s base libraries. It’ll raise my alarms more readily when reviewing/auditing code.
It’ll still be a while before I’m even able to take advantage of
QualifiedDo
, but I still look forward to pushing the
boundaries of this idea.