Hacker News new | ask | show | jobs
by dkarl 1748 days ago
> Haskell, in many of its uses, cleans up the tediousness so thoroughly that the code written using errors as values can be almost indistinguishable from code written using exceptions, and yet, nevertheless, the errors are values and no exception machinery is being deployed.

I don't have experience with Haskell, but I have mixed feelings about monadic error handling in Scala for precisely this reason. It goes to great lengths to recreate the programming ergonomics of exceptions, with exactly the same drawbacks. Monadic error handling, aka "railway-oriented programming,"[0] splits your logic into two tracks: a "good" track, where all your happy path logic lives, and a "bad" track, which is automatically propagated alongside your happy path logic. In my experience, it induces the same programmer mistakes as exceptions do: errors get accidentally swallowed (especially where effects are constructed and transformed,) different errors that require different handling are accidentally treated the same, and programmers fall into the habit of seeing the error track as an inferior, second-class branch compared to the happy path.

It confuses me when programmers (not talking about you, because I don't know how you write code, but people I've worked with personally) bash exceptions and then use monadic error handling to achieve exactly the same trade-offs.

This hasn't turned me off of monadic error handling, but it has made me think of it as FP's version of exceptions, rather than an upgrade. Personally, I think exceptions are a good enough trade-off in most cases, but when you need to be more careful, it is better practice to give all paths the same prominence in code. FP provides a better way to do this: pattern matching. More verbose, yes; harder to spot the happy path when reading code, yes; encourages more careful and thorough thinking about errors, for me absolutely yes. YMMV.

[0] https://fsharpforfunandprofit.com/rop/

1 comments

> This hasn't turned me off of monadic error handling, but it has made me think of it as FP's version of exceptions, rather than an upgrade.

This reminds me a lot of Java's checked exceptions just with different window dressing. You move the failure mode type information from the exceptions list ("throws" clause) into the return type.

Typed error return values is definitely an improvement in ergonomics over C-style error code returns, and pattern matching is definitely a big improvement on ergonomics too, but I think error handling has a problem of fundamentally irreducible complexity. For example, if you make network calls, you have to be prepared for network calls to fail, and you have to design your system to recover from it somehow, whether that happens in a try/catch or a match on Either[Throwable, Result].

I think trying to reduce the complexity of error handling is the original sin. Thinking of error handling as a separate case requiring separate mechanisms is the original sin. The structure of your code should not reflect any difference between "error" and "success" cases. They are equal, and neither should be subordinated to the other.
I've started writing a blog post that covers this topic, and once I started thinking clearly about it, I've found errors to be a really hard problem.

To even get out of static vs. dynamic typing, I've expressed the problem as "Given this piece of code, how can I know what types of errors will come out of it?", where I'm using a human, loosey-goosey sense of the word "type" here, rather than necessary a strict type. (If your language wants to answer that in terms of strict types, great, but I'm trying to answer it very generally across programming languages.) It turns out that from what I can see, the underlying problem is that "errors don't compose"; given a function f that returns errors X, Y, and Z and accepts another function f2 to call that may return other errors, it is really difficult to characterize f(f2) in practice. For a concrete, static f2 we can mostly at least imagine taking the union of the two (although even that can be an oversimplification; what if f calls f2 in such a way that one of the types of errors that f2 can produce is guaranteed not to happen, e.g., what if f2 could throw a null pointer exception but it can be easily statically proved that f never passes it one?), but once you let enough polymorphism into the mix to let f2 be an arbitrary closure of some type, all bets are off in terms of what f(f2) can produce in most languages.

This hurts statically-typed languages in that they can't create very strong types for these sorts of situations, but more generally, translating the theory up to practical experience regardless of the language being used, A: it's hard to program in an environment where you have enough polymorphism of some sort (OO, accepting closures, whatever) to have errors mix like this in your code and know what sort of errors may occur where and B: that's nearly everything because programming in an environment that lacks that polymorphism is not something we generally do voluntarily. (Embedded code not allowed to even allocate on the stack is this static, but we can't build everything that way.)

Amusingly, I think what saves us in the end is that to a first approximation, there is no such thing as error handling. All there is is logging something for a human and giving up. Obviously, to a second approximation there is a such thing as error handling. I've got plenty of it. But honestly, it's pretty rare by percentage. Most errors result in a log message and some level of failed task. Fortunately, with some work, we can usually get our systems fed enough good data that we can do things without errors, such that the ones that do occur end up almost always being essentially correctly handled by screaming and dying. If programming actually required us to handle errors, like, in some intelligent manner all the time, we'd have a lot fewer programs in the world!