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
forever with this syntax. That’s a downer.
However, I think it also follows naturally. If
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 (
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
Either E A. The downside being that if you use a
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.
The next case considered was
try, which has this
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
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
try will catch it. So it wasn’t
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
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
data Failure err ok = Failed err | Ok ok
Now, it’s not possible to be confused about
some random data and
Failure which only ever is produced by
a failure. Many have argued that
Either itself is a form of
blindness. I used
Either in my post as an aid to
demonstrate a point; there’s no compelling reason otherwise to use
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
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
The author writes that for,
would necessarily discard the result of
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
functions can swallow results all the time, regardless of whether you’re
Try.do or writing normal Haskell. In Scala, Kotlin
I’m glad the author brought this up, because we don’t currently have
any tooling to find and catch these mistakes. Essentially,
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
Is it a criticism of
Try.do? I’m doubtful at the
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
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
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.