Hacker News new | ask | show | jobs
by flowerlad 1866 days ago
> Errors as values is a far superior approach to exceptions.

Why is that? I have never seen a cogent explanation for why this is the case.

I can tell you why exceptions (as implemented in Java) are cool: You can write code as if every function call is successful, as opposed to adding a line (or more) of error handling code after every function call, which makes it harder to follow the logic.

6 comments

I write Java, but I prefer errors as values exactly because you can't only consider the happy path. It really makes you think about the appropriate response to this specific failure. You can do that with exceptions, but in practice it's exactly like you say: all error handling is delegated to some generic catch-all, which in a web app usually just gives a generic 500 Internal Server Error.

If I encode my errors as values (usually with Either), I have to decide how to gracefully fall back if a failure occurs before I'm even allowed to use the successful result. Maybe I just hide the part of the view that needed that data. Maybe I show an appropriate error message. Maybe I email operations to let them know about the problem. Whatever I do, I have to actually think about it and not just assume that the error will be caught somewhere by someone. The result is usually a dramatically improved user experience when failures inevitably occur.

Exceptions tend to pass the buck so far down the line that there's no context to make an appropriate decision. Values tend to force a decision early, when you still have enough context. (Obviously both can be used against the grain, but the question is which pattern is easier.)

> It really makes you think about the appropriate response to this specific failure.

I beg to differ. It forces you think about whether but not why the function itself has failed. The "why" is embedded in the type of the error (or exception) itself, but Go does not force you to examine the type of the error; indeed, sheer muscle memory compels you most of the time to just write if err != nil again and again.

> I can decide how to gracefully fall back if a failure occurs before I'm even allowed to use the successful result.

You could wrap every line of Java code in a try/catch if you wanted to (it wouldn't be idiomatic, but it's definitely possible). You're just not forced to.

For what it's worth, you also shouldn't email your ops team directly from production code. It doesn't scale. You should log the error, and your monitoring stack should handle altering the relevant team (full disclosure: I work for such a monitoring stack company). It's very rare that you actually want to recover from errors, as that's typically a pattern that leads to silent failures and difficult to diagnose issues.

Yeah, I'm not endorsing Go's approach to errors, just the idea of errors as values. I can't speak for Go, but other languages make it very obvious that to handle an error you have to inspect its type, and thereby get at the "why".

The lack of forcing to handle (checked) exceptions is exactly why I dislike Java's model. Until I've checked for an error state, I want to have an Either that may or may not have the data I'm looking for (and if it doesn't, has an explanation). In a truly exceptional situation I can crash and give a 500 error, but checked exceptions are by definition supposed to be recoverable, and in a production codebase I don't want to be able to lazily avoid recovering from them.

You're differentiating between Java the language and Java the ecosystem. Java's tooling is so strong that you can use static analysis tooling to fail builds that throw checked exceptions, if you want. See e.g. https://rules.sonarsource.com/java/RSPEC-1162
That… sounds like exactly the opposite of what GP wants? If you can’t throw checked exceptions then you can only throw unchecked exceptions, whose catching and checking is even less enforced by the compiler.
GP is differentiating between library code and application code. You wouldn't turn on the static analysis to fail the build on checked exceptions in library code, since you want to force the application to keep track of known error modes in libraries and decide where it's appropriate to handle them. But you would turn them on in application code in order to prevent application code from using exceptions-as-control-flow, which is an anti-pattern and one of the reasons why people who value Go's error handling sometimes hold their opinions because of scarring they suffered from anti-patterns that were in Java codebases they worked with.
I'm not sure where all these comments which specifically mention Java are coming from. In Java you MUST catch or forward all exceptions besides those derived from RuntimeException, and you MUST specify them in the type signature. RuntimeExceptions are equivalent to go panics, so you don't have to catch them.

Java never lets you ignore an non-panicking exception. Unlike Go, where you CAN ignore the error value.

Besides that, Java forces you to explicitly state what type of exceptions each function can throw.

Java exceptions are stricter than Go, not vice versa.

Now, if we were talking about .Net or JS, or Python or mostly any other language with exception I'd get this criticism, but it is patently false when it comes to Java.

I think Java in particular sticks in a lot of people's minds because, while the ideas are there, the execution is not - the standard library sets a poor example that is generally followed elsewhere.

An attempt to read from a Reader, for example, can fail with a java.io.IOException. The javadoc for that lists 31 direct known subclasses, including CharConversionException and JMXServerErrorException; and there is always the possibility of a custom subclass from somewhere else in your application or a 3rd party library.

You can't do anything sensible with such a broad error (like decide if retrying might be sensible), so you end up either propagating it in your type signature, wrapping it in a RuntimeException, or ignoring it.

For what it's worth, I believe many I/O exception sub-classes came from before the time wrapping exception was established as a best practice in Java (as it has been in Go recently, with the introduction of fmt.Errorf("%w")).

I agree that the Java standard library (especially the older parts of it) is quite bad. The Go standard library, whatever issues it has[1], is still pretty solid.

Unfortunately, the questionable quality of the Java standard library and the Java EE libraries back in the day, have led to some of the bad patterns we see nowadays in Java, that are not necessitated by the language, e.g. gross abuse of inheritance.

I still want to point out here that error values are not superior to exceptions, since:

1. Java shows exception handling can be forced to be explicit as well.

2. Handling Go errors is NOT forced. In fact, you can always ignore the functions' return value or assign the error part of it to a `_`. This is far less explicit than an empty catch block.

[1] https://fasterthanli.me/articles/i-want-off-mr-golangs-wild-...

> Exceptions tend to pass the buck so far down the line that there's no context to make an appropriate decision.

This is not true, and therefore your explanation is unsatisfying.

Can you please elaborate how the pattern that you describe doesn't pass the buck to a portion of code that has less context for the cause of the failure?

> You can write code as if every function call is successful

How does writing code as if every function call is successful (when you know that some functions will fail) not lead to some other bit of code further up the call stack having to make a decision about an exception that it doesn't have the context for? A bit of code which probably was written by a different developer who didn't anticipate what you were going to do?

A library knows what happened, but not what to do about it. It doesn't know who the caller is. It could be an interactive user or a microservice RPC or a Spark job or a debugger.

There have been frameworks like the Common Lisp Condition System to let a caller dictate a recovery/retry policy, but they never caught on. In practice "do no harm, give up, and report the error" is what almost everyone wanted, and most languages support it without punitive effort.

> Can you please elaborate how the pattern that you describe doesn't pass the buck to a portion of code that has less context for the cause of the failure?

Copy/pasting another person's answer:

You could wrap every line of Java code in a try/catch if you wanted to (it wouldn't be idiomatic, but it's definitely possible). You're just not forced to.

You can handle some of the exceptions in the same function, without going to the extreme of wrapping every line of code in a try/catch block.

> You could wrap every line of Java code in a try/catch if you wanted to (it wouldn't be idiomatic, but it's definitely possible). You're just not forced to.

Yes you ARE forced to. Unless the exception derives from RuntimeException.

Look at any piece of Java code and try to guess the cyclomatic complexity of it. It's not simple. Because every line, and every function call can fail. And that failure is one more place where the execution tree of your code branches. You can't see it unless you check every method call in your code.

In Go -- every error is obvious, and you get a sense of a cyclomatic complexity of any piece of code by just going through it quickly.

So, that's it for me. The complexity of the code is visible.

I think it's a little bit complicated to explain but mostly it boils down to this: errors are real. Java methods kind of let you ignore them through declaring exceptions, with this idea that, well, somebody else will deal with it. Golang functions make errors feel more present. They force you to think about how you're going to handle the errors up front, and to question whether or not you even should error in a particular instance. It's actually helped change the way I think about functions. There are many behaviors that in Java I would not have even considered making them idempotent but in golang making them idempotent is both easier and, as it turns out, more robust.

The patterns for error handling the golang have introduced are admittedly verbose, but they do lend a certain element of confidence that once the code is written, the errors should be handled. Of course a programmer can ignore the errors explicitly but doing so is different than forgetting to catch a thrown exception, because the programmer must go out of their way to write code ignoring the error. It feels like there's more agency around the decision.

> Java methods kind of let you ignore them through declaring exceptions, with this idea that, well, somebody else will deal with it.

Sure, you can write sloppy code in Java, but I am sure you can write sloppy code in Go too.

The difference is that with exceptions sloppy code is the default.

As you write code in a language with explicit errors, the language makes you acknowledge that the code you call can error out. This makes you stop and think what to do about the error. You can choose to ignore it, but that's a conscious decision that the language forces you to make.

With exceptions, there's no such feedback mechanism from the language/compiler. In order to write robust code you yourself must have the discipline to add exception handlers around the appropriate calls.

In short, defaults matter. It's simply easier to write correct, robust code when you don't have to go out of your way to do it.

See https://devblogs.microsoft.com/oldnewthing/20050114-00/?p=36...:

> It’s really hard to write good exception-based code since you have to check every single line of code (indeed, every sub-expression) and think about what exceptions it might raise and how your code will react to it.

... which leads us straight to favorite exceptions-caused bug: https://nedbatchelder.com//blog/202001/bug_915_solved.html:

> Inside tempfile.NamedTemporaryFile, the error handling misses the possibility that _TemporaryFileWrapper will fail.

I haven't used Java in a while, but didn't Java require you to declare all exceptions your method will throw?

You could ignore those, but similarly you could ignore err in Go as well.

Only checked exceptions. And the integration of checked exceptions is so bad, and the split between checked and unchecked so arbitrary, that most codebases have sworn off of them.
the fundamental tenet in Go is that every error should be handled. Here is an excerpt from Dave Cheney that clarifies this:

"For the truly exceptional cases, the ones that represent either unrecoverable programming mistakes, like index out of bounds, or unrecoverable environmental problem, like running out of stack, we have panic.

For all of the remaining cases, any error conditions that you will encounter in a Go program, are by definition not exceptional — you expect them because regardless of returning a boolean, an error, or panicing, it is the result of a test in your code"

> the fundamental tenet in Go is that every error should be handled.

That's true in Java as well.

I think the point is that in Go, you expect failure. Failure is not exceptional. It's a first class part of the logic.

Java conditions you to view failure as an exception to the rule, and the happy path as the real code (which is the attitude you express in your first comment). Lots of us have since observed that this approach to failure leads to errors because programmers ignore failures, allowing them to be handled by a catch-all at the top of the stack, which typically just dumps a stacktrace and calls it a day.

The paradigm espoused by Go sees errors as just another program state, and one whose implications are just as important to consider as the desired behavior. This forces programmers to consider all the implications of a failure, rather than just adding another `throws` clause.

> Lots of us have since observed that this approach to failure leads to errors because programmers ignore failures, allowing them to be handled by a catch-all at the top of the stack, which typically just dumps a stacktrace and calls it a day.

That's not typical Java code. You could do that in quick & dirty code, but I haven't seen such code in production code.

> That's not typical Java code. You could do that in quick & dirty code, but I haven't seen such code in production code.

It sounds to me like you're an extremely lucky person. I've seen too much of that sort of thing in production code, including a catch-all for Throwable at the top that didn't even dump a stacktrace.

Anecdata: but I see this pattern all the time in production code. It’s not limited to Java: I’ve seen it in TS/JS, Python, and C++. I haven’t (yet) seen it our Go codebases.
Typically this is passed to production and not seen by devs. Ie. there's no point to handle NullPointer explicitly (programmer error).
Then you're lucky. I have, both on the developer side and on the user-getting-a-stacktrace side. It's not pretty from either end.
I'm working with some Python that follows this error as value convention and it's awful. The code likes to catch exceptions as soon as possible and replace them with strings like "not found". Exceptions are useful things, they tell me what broke and where. Instead I get a "not found" error value, and find out that happened because the endpoint called a service that returned a "item not found" error value, and then a few layers of obscure string error values later I find out some logic did division by zero or something.

In other words, I also don't understand the hype around error values.

So, back to Python, what error value is suitable for replacing an exception? I mean, Python is flexible enough to do Go-style error values, but what does a good error value look like? Maybe I just haven't seen good error values?

If an error happens way down in the weeds and I initially return a very specific error value, do I just pass it up the stack forever? If so, how is that different than traditional exceptions? Do I replace the specific error value with more general error values as I move up the stack to higher-level code? If so, then I am throwing valuable information away and am likely to end up with something like "not found" as my error value at a the top -- not very useful.

In Go, I would do one of: * Pass the error value up unchanged * Wrap the error inside another error * Pass a new error * Swallow the error and do something else to cope with failure

I would probably also do some error logging, capturing the state of relevant local variables, especially if it is one of those cases where "see an error" is unexpected.

Thanks for the answer. It sounds like pretty much the same thing good exception handling would do.

I suppose they're two sides of the same coin. Both can be done well or poorly.

I do resent the idea that error values are automatically better or easier to understand, because I'm dealing with some poorly done error values now and wishing for traditional exception handling.

I guess the differences I see are between "with exceptions, you can accidentally forget to handle the errors in the right place, and you end up handling them elsewhere" vs "with error values, if you forget to handle the errors, you don't handle them at all" on one side. And "with exceptions, you do't necessarily have the immediate feedback that one may come" vs "with error values, you need to choose between sticking them in an error value variable, or explicitly ignoring it" (and with Go's fondness for "you assign to a variable that is then never used? That's a compile error!" it essentially means "you either handle the error or you intentionally disregard it".

Either is fine, I think. But, don't mix both in the same codebase. And with exceptions, I would really like Common Lisp-style restartable errors, to get the flexibility I feel I have with error values.