DEV Community

Cover image for Error as Value vs. Exceptions
Martin Häusler
Martin Häusler

Posted on

Error as Value vs. Exceptions

In the last ~20 years, exceptions have been the predominant way to handle errors in programming. Java, C#, C++, Python and many others offer this feature and the concept is pretty similar in all of them. With newer languages like Go and Rust entering the stage, we see the rise of a different way of error handling: errors as values. In this article, we're going to take a closer look at this new paradigm and I'll give you my thoughts on it. Please note that this piece of text is an opinion and not a universal truth, and it may change over time.

What is "Error as Value"?

The basic idea behind the Error as Value pattern is to return dedicated error values from a function whenever this function encounters an invalid state. Contrary to the exception workflow, Error as Value suggests to return the error as a result of the function. This way, the error becomes part of the function signature, which leads to two major effects:

  • Everyone who is calling the method will see at first glance that it can produce an error

  • Everyone who is calling the method will be forced to deal with this potential error situation in their code (languages vary in how strict this is being executed upon)

The implementation details of this pattern vary. For instance, Go functions often return pairs - the first entry of the pair is the actual result (if any) while the second entry is the error which has occurred (if any):

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f
Enter fullscreen mode Exit fullscreen mode

The Go compiler will not allow you to have unused variables, so you're forced to check the err value.

In Rust, things are handled a bit differntly, but ultimately to the same effect. Rust has a generic Result<T, E> enum which has two concrete implementations: Ok and Err. Any function which returns a Result<T,E> can thus produce an actual result or an error, and the caller has to differentiate between the two:

    let fileResult = File::open("hello.txt");

    let f = match fileResult  {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
Enter fullscreen mode Exit fullscreen mode

Advantages of Error as Value

The proponents of this approach will often cite the following advantages:

  1. Errors become parts of the function signature

  2. The compiler forces the programmer to deal with the errors

  3. Errors as values have superior performance to exceptions

  4. Errors as values provide clearer control flow

My thoughts on Error as Value

Please note that everything that follows is my informed opinion as a server side developer and mine alone. It is a hot take, and you may disagree with it violently.

Errors should not happen.

The argument that Error as Value is better for performance, while technically true1, has zero significance in practice to me. If our program ends up in an error state for some reason, we're already disqualified from the performance race, because our program went off the track. There's no reason to optimize for performance in an error state because they should occur rarely to begin with.

If your program uses errors for regular control flow, you were doing something wrong in the first place.

Real recoverable errors are a myth.

I've often heard, in different contexts, the distinction between a "recoverable error" and an "unrecoverable error". To me, this has always been weird and artificial. The philosophy in early Java was that checked exceptions are to be used for recoverable errors. By that definition, how is my API request going to recover from an SQLException which tells me that my commit cannot proceed because it violates a data constraint on the database server? It won't. The best thing we can do is to log it and send it to a central error collector to get the issue fixed by changing the source code. The program (i.e. the server as a whole) may "recover" (i.e. the error in the processing of the single request doesn't terminate the entire server) but the request cannot be salvaged at this point. Attempting to deal with an SQLException on the level of the method which called commit() is therefore futile, as it has no alternative way to continue.

Java was the first language to introduce "checked exceptions" which you're forced to:

  • either declare explicitly in your method signature
  • or are forced to catch and process within the method itself

To this day, Java is the only language I know which has this concept. C# didn't inherit it, and even JVM aliens like Groovy and Kotlin ditched it completely. The fact that any modern Java framework or library will also forego checked exceptions in favor of unchecked ones should be enough to tell you: checked exceptions (while nice in research papers and conference talks) turned out to be a really impractical idea.

For Rust and Go, Error as Value are recommended to be used for "recoverable errors" and panics (which we will discuss later) for unrecoverable errors. Do you see the pattern? It's checked exceptions all over again.

The unfailable function fallacy

The Values as Errors methodology encodes errors in function signatures. However, if you look at Go and Rust standard libraries, not all functions in this methodology have errors in their signatures. This implies that there are functions which cannot fail.

In a perfect world, we would be able to write functions which can never fail. However, that is not the case in practice. Consider these:

  • A simple integer addition a + b can "fail" if the values are sufficiently high enough to exceed the range of representable integers.

  • You're calling another function in your function. This additional call may exceed the maximum stack size of the platform you're running on. This crashes your program, but the function itself did nothing "wrong".

  • You're sending data over the network, but right in the middle of it, the connection is lost. Maybe you're on a mobile network and entered a zone with bad reception. Maybe somebody physically unplugged the ethernet cable. None of this is the programmer's fault, but it will cause a function in the program to fail.

  • You're calling malloc (every non-trivial program does, one way or another) and the underlying runtime has no memory left for you.

The key message here is: your program will fail, in arbitrary places, for arbitrary reasons, no matter how well-engineered it may be. Outside of academic boundaries, there's no way to protect us from that. How we deal with these situations is the key question.

Going back to Rust and Go, if we're serious about Error as Value, then every function would need to have an error output, from simple arithmetics to database commits over the network. Needless to say, this is enormously impractical (which is why Rust and Go "chicken out" with panics; we will talk about those in a moment).

The conclusions I draw from this are:

  • Every function can and will fail, no matter which task it attempts to perform.
  • We cannot conceivably enumerate all the different reasons why it failed, and it is futile to try.
  • The attempt to encode errors as part of function signatures, while valiant, is impractical and ultimately futile.

This eliminates the advantages of "clearer control flow" (it's not) and "including errors in the signature" (you can't, not exhaustively at least).

Don't panic

Rust and Go, aside from Error as Values, have another "failure mode", which is called "panic". When a panic occurs, the code exists the normal control flow, exits the current function, exists the calling function, and so on, until the program either terminates as a whole, or (and this is the fun part) it reaches an error boundary. In Rust, that's called catch_unwind, in Go it's called recover.

If this process of "exiting functions through a different control flow" seems familiar to you, it is because this is exactly how Exceptions behave.

Rust and Go have exceptions. They just don't tell you.

To me, that is the point where the entire house of cards with Error as Value collapses under its own weight. This is hypocrisy.

The merit of try-catch

Exceptions and try-catch blocks have a lot of merits which people tend to forget:

  • they offer a structured way of handling exceptions
  • they represent error boundaries which apply to everything that happens in the try block, no matter if it's in the same function or way down the call chain
  • they free you from dealing with each error in every place individually and centralize error handling to the places where errors can actually meaningfully be dealt with
  • they de-clutter control flow for all down-stream functions because they don't need to handle their own exceptions
  • exceptions automatically track the place they've occurred in

The last point is especially important. Sure, in Rust, you can use the ? operator on any function which returns a Result<T,E> and it will exit out of the current function if an error is present, otherwise it will give you the plain result. It's cool, but... if you eventually decide to look at an error, you'll have no clue where it came from. Tracking this down can be really hard. Yes, there are ways in Rust to manually attach tracking information to error objects, but at this point I feel Rust is just attempting to fix the problems it created for itself. In Go, to my knowledge there is no such thing. It's if err != nil galore all the way.

The true drawbacks of Exceptions

From my point of view, there are a couple of problems with exceptions:

  1. Exceptions can be overlooked. The library function you're calling may be throwing an exception, and you're not aware of it as a programmer.
  2. Nothing forces you to eventually deal with exceptions.
  3. Exceptions and try-catch can be abused for regular control flow.

Sure enough, all three of those are tackled by Errors as Values. But to me, it creates more problems than it solves. The issues I've mentioned above are matters of programmer discipline and clean code. I realize that I may sound like a C programmer talking about malloc and free when the first garbage collectors were introduced. But Error as Value is so invasive to the code structure that I can't imagine working with it in any serious fashion.

Error as Value in Kotlin

Kotlin sits somewhere in the middle. It does have exceptions (inherited from Java) but it also has it's own way of doing Error as Value. Consider this method signature:

fun String.toIntOrNull(): Int?
Enter fullscreen mode Exit fullscreen mode

The standard library in Kotlin defines a toIntOrNull() method for String. It will parse the string, and if it is parseable into an integer, the integer will be returned. Otherwise, null will be returned. As Kotlin is a null-safe language, the programmer is forced to handle the null case separately. This is in a very similar vein as Error as Value, but with the error being restricted to the null value. The downside is that the error case cannot carry any semantic information (why did the parsing fail? At which position?). The advantage is that it is syntactically very pleasant and very clear. As a developer calling this API, you cannot use it the wrong way (the compiler won't let you) and you cannot forget anything. I've grown rather fond of this approach. One caveat is that it only works for methods which have actual return values, and that null must not be a "regular" return value for the method (otherwise you can't distinguish between null as in "whoops that's an error" or null as a regular non-error return value).

Conclusion

That's all I've got to say today. I hope you've found it interesting, it's just a topic that's been on my mind for quite a while now. And even though I'm fully on the "team exceptions" at the moment, I'm not excluding the possibility that my mind will change in the future if the arguments are compelling enough. And I think we can at least all agree that both solutions are better than <errno.h> in C.


  1. Exceptions are notoriously slow compared to regular control flow. The main reason for that is that most languages which utilize exceptions include a stack trace in the exception, and collecting this information is costly. 

Top comments (2)

Collapse
 
erdo profile image
Eric Donovan • Edited

That's a good reminder that any function can indeed fail (out of memory on mobile apps for example) and you obviously wouldn't want all function signatures to include Errors.

I mainly work in Kotlin on mobile apps and I do like using Eithers for making network calls specifically (where getting an HTTP 500 will typically cause the networking library to throw an exception for example, and as you know the Kotlin compiler doesn't check for these). I often encounter code in the wild which doesn't handle these exceptions at all and subsequently crashes as soon as the user encounters a flaky network 🤦‍♂️

Wrapping those calls in a kind of client middleware layer that handles the various networking related issues in one place (including json parsing exceptions) and presents them to the rest of the app as Either<Result, Error> seems to work nicely for me, and the API then forces other developers to think about what to do if they get an Error here: in the event of a Error.SessionTimeout redirect to the log in screen for example.

I also like the fact that it provides a bit of separation between our data source code and the domain code (the domain layer knows about Error.RetryLater, Error.SessionTimeout or Error.Misc etc but not HttpExceptions).

This is indeed a weirdly emotional topic though 😂 lots of people have very strong opinions about it for some reason 🤷

Collapse
 
martinhaeusler profile image
Martin Häusler

I often read and hear that error-as-value is the universal cure for error handling, and based on everything I've seen it's just not true. I completely agree with you that there are specific cases where it makes sense to do it, and I've actively used it myself. All of the doOrNull methods in the kotlin standard library are essentially errors as values and they're fine. The issues crop up when this gets pushed too far, and as far as I'm concerned, both rust and go are guilty in that regard. And even these languages resort to panics (because they need an escape hatch), but in spite of its name, a panic doesn't necessarily terminate the whole program, they can be recovered from. But that's just an exception mechanism, and I'd wager that the tooling and debug info you'd get from that is far worse than what you'd get from an exception. So we actually don't gain much by removing classical exception from our toolbox.