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
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:?}"),
};
Advantages of Error as Value
The proponents of this approach will often cite the following advantages:
Errors become parts of the function signature
The compiler forces the programmer to deal with the errors
Errors as values have superior performance to exceptions
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:
- 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.
- Nothing forces you to eventually deal with exceptions.
- 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?
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.
-
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. ↩