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
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.
The 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:
trywill catch exceptions thrown by the GHC exception mechanism.
Leftcase of the either.
In this example:
Ean instance of
Exception. I don’t think there would be a good reason to do that. But let’s continue with this assumption.
baseto throw your result type (again, why would you?), then
trywill catch it. So it wasn’t lost.
resultvariable 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
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
The author writes that for,
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,
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 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.