Rust Solves The Issues With Exceptions
Table of Contents
A small topic that’s too big to fit in a larger Rust post.
Disclaimers
- Rust isn’t the only language that doesn’t have exceptions and handles errors by value. But error handling in languages like Go is flawed in its own way. Here, I don’t discuss these other implementations and use Rust as a specific successful example of exception-free error handling.
- I use Java for most examples of exceptions because it lets me discuss checked exceptions as well. For unchecked exceptions, this shouldn’t matter because the implementation is very similar in most popular languages.
- I ignore third-party libraries and discuss common, “idiomatic” mechanisms provided by the language.
Issues with exceptions
Exceptional flow
Just like a return value, an exception is a value.
Just like a return value, an exception is “a result of calling the function”.
Despite this, exceptions introduce a special try-catch
flow which is separate
from normal return
s and assignments. It prevents ergonomic function
composition
.
You can’t pass the “result” of a throwing function directly into another
function, because the return value is passed only when the first function
completes successfully. Thrown exceptions aren’t passed in and propagate out
instead. This is a very common error-handling pattern, and I get why people want
to automate it.
But when you need to pass the error data into the second function, Java makes
you write a verbose try-catch
block and cook a home-made
Result
type:
// We're given `GReturned g(X x) throws GException` and `f(/* result of g??? */)` which we control.
// Let's generously assume that we also control the definitions of `GReturned` and `GException`.
// This allows us to avoid extra wrapper objects and implement a sealed interface directly.
// As far as I know, this is the most simple and performant implementation of sum types in Java.
sealed interface GResult permits GReturned, GException {}
class GReturned implements GResult { /* ... */ }
class GException extends Exception implements GResult { /* ... */ }
GResult gResult;
try {
gResult = g(x);
} catch (GException gException) {
gResult = gException;
}
f(gResult);
Compare the snippet above to our original idea, which works as intended if g
can’t throw:
f(g(x));
Exceptions hurt most error-handling patterns. Even very common ones, like rethrowing a wrapper exception. As you’ll see in the next section, this pain isn’t even necessary to have convenient propagation. Exceptions aren’t worth the cost.
This is a great example of why automatic error propagation is tricky and may
lead to bugs. In Rust, the equivalent of that buggy And this buggy expression wouldn’t even compile! Can you guess why I used an intermediate variable instead of calling
f(g(x))
and f(gException)
in try-catch
?try {
f(g(x)); // <- If `f` also happens to throw `GException` and does this when `g` didn't...
} catch (GException gException) { // <- then this will catch `GException` from `f`...
f(gException); // <- and then call `f` the second time! 💣
}
f(g(x))
expression would
look like f(g(x)?)?
, clearly marking both points where a jump / early return
happens and making the bug easier to notice.f
accepts a Result
, but in
Rust, the “successful” value doesn’t implicitly convert into a Result
. It
needs to be explicitly wrapped: f(Ok(g(x)?))?
. This is the real, working
equivalent of that Java’s f(g(x))
. It looks ridiculous! It immediately
indicates a fishy situation, and eventually leads us to the right solution:
f(g(x))?
. Thanks to u/sasik520
for
pointing this
out
.
Even “typical” error wrapping is unergonomic
Exceptions always force you to write a whole special try-catch
block that
can’t be abstracted away:
try {
h();
} catch (InnerException e) {
throw new WrapperException(e);
}
Meanwhile, Rust’s error propagation is abstracted into a ?
operator
that can perform the wrapping for you:
h()?;
This automatic conversion only works when WrapperException
implements
From<InnerException>
. But this is the most common case. And even in other
cases, this is still just a regular data transformation that can be expressed
concisely using regular functions:
h().map_err(WrapperException)?;
That’s all it is. Error wrapping doesn’t have to be more complicated than that.
Unchecked exceptions
Traditional unchecked exceptions are dynamically-typed “result” values. Any function can throw (or not throw) any exception. This makes programs unpredictable.
Not only the “returned” error values are unpredictable, but especially the
control flow. Remember that exceptions aren’t returned and assigned as “normal”
values. Almost any line of code can throw an exception, interrupt the current
function, and start unwinding the stack. Potentially leaving your data in an
inconsistent, half-way modified state. But programmers can’t always keep
exception safety
in mind and
get it right on their first try. No one wraps every line in a try-catch
. The
result is unpredictable, unreliable programs with poor error handling. “Unhappy
paths”
are more
important than they seem:
Almost all (92%) of the catastrophic system failures are the result of incorrect handling of non-fatal errors explicitly signaled in software. 1
Unchecked exceptions aren’t reflected in the type system, but they are still
part of a function’s contract. Many style guides recommend manually documenting
the
exceptions
that each public function throws. But soon these docs will get out-of-date
because the compiler doesn’t check 2 the docs for you. Callers
can’t rely on these docs’ accuracy. If callers want to avoid surprise crashes,
they always have to remember to manually catch Exception
. And you know how
that
goes
…
Checked exceptions in Java
Checked exceptions seem like a reasonable reaction to these issues with unchecked exceptions. Potential errors are included in the method’s signature (as they should) and force the caller to acknowledge the possibility of an error (as they should).
But the actual implementation in Java is very flawed. There are entire discussions on using only unchecked exceptions, as does every other language with exceptions. I found the following root causes:
- Java’s type system can’t represent checked exceptions generically
3. You can’t have an interface that throws an unknown, generic
set of checked exceptions. An interface has to either:
- Throw no checked exceptions. This forces the implementors to wrap and
rethrow these as an unchecked
RuntimeException
, losing type information.Runnable.run
is an example of this. - Throw a specific, made-up list of checked exceptions that may not make
sense for all implementations.
Appendable.append throws IOException
is an example of this.
- Throw no checked exceptions. This forces the implementors to wrap and
rethrow these as an unchecked
- Throwing a new checked exception from a method is always a breaking change. Because of this, libraries with a stable API might decide to throw an unchecked wrapper instead. Callers can still catch and handle it, because…
- Unchecked exceptions are recoverable. No wonder people go against the
“official”
recommendations
and use them for recoverable errors! The
try-catch
mechanism is the same for all exceptions. You’re always just oneextends RuntimeException
away from pleasing the compiler without a big refactoring. Which is needed, because… - The lack of union types and type
aliases
infamously forces the
programmer to update the
throws
clause in every method all the way up the stack (until that exception is covered by acatch
that swallows or wraps it).
If we had a type system that solves these issues, checked exceptions would be a pretty good deal! Definitely better than unchecked exceptions that we see today in most popular languages.
But even improved checked exceptions would still suffer from the general issues with exceptions (described in the beginning of the post). Now, let’s see how Rust solves all these issues for good.
Solutions in Rust
Rust gracefully solves these issues by having:
- Errors as “normal” return values that work with “normal” assignments and function calls.
- “Sum types”
that allow
expressing things like “this is either a value or an error” or “this
is one of these possible errors”. Rust
enum
s are like sealed interfaces, but much more ergonomic, efficient (no indirection), and flexible (you can’t implement interfaces for types that you don’t control). - Exhaustive pattern matching that forces the programmer to handle every possible case (including errors, if the possibility of an error is indicated in the type).
- A standard generic
Result
type with methods likemap_err
to help in typical scenarios like adding context to errors. - A rich type system that allows handling errors generically without losing type
information. The
E
inResult<T, E>
is the prime example of this. - A compact
?
operator to convert and propagate errors. It makes error propagation as ergonomic as when using exceptions (and more ergonomic when you need to wrap the error). But it’s also explicit and visible in a code review, as we’ve discussed in the section about a trickyf(g(x))
. - More syntax sugar like
if let
,while let
,let-else
. - The
#[non_exhaustive]
attribute to solve the API stability problem where necessary. It’s not unchecked, it still forces callers to handle unknown error variants from the future! 4 - Unrecoverable 5 “panics” that are clearly separated from the “normal” value-based error handing (everything described above). Panics are used as:
Rust’s own issues
It would be unfair to end the post here and declare that Rust has the best error handling because it solves all issues found in another language. Rust’s approach inevitably brings in some new, different issues:
- When your function can encounter multiple different errors and you want to
preserve concrete type information 7, Rust makes you manually define
a wrapper enum and implement the conversions. This is necessary because Rust
doesn’t have anonymous unions
.
But the boilerplate isn’t too bad and even built-in solutions like
?
eliminate some of it 8. - There’s also a more specific issue with those wrapper enums. Sometimes, they
tend to grow and be shared across multiple functions, where not every function
actually returns every error variant. This undermines the benefits of
exhaustive pattern matching and types-as-documentation, leading to…
hand-written docs that list the errors that the function can
return
!
Sounds familiar, huh? The experimental
terrors
library does a great job of describing this issue . - Moving around bloated return values and explicitly checking those can
sometimes hurt
performance
.
But this can be solved by boxing the error, using output parameters or
callbacks, or using global variables. Besides,
Result
and other enums are often “free” in terms of memory, due to niche optimizations . Some errors may even be represented as zero-sized types . - Even though Rust supports local (non-propagating) error handling much better than languages with exceptions, it’s still not perfect and can over-emphasize the “stop on the first error” pattern. Reporting multiple errors at once isn’t supported just as well and may require third-party libraries or verbose in-house solutions. I discuss this issue in more detail in my in-house solution which you can use as a third-party library 😁
- As mentioned in this post about .NET , exceptions usually provide good insight into the origin of an error, with tools like stack traces and debugger breakpoints. Tracing the origin of an error value in Rust is a more demanding process that may require: manually enforcing best practices around logging and adding context to errors; manually capturing backtraces where necessary; longer debugging sessions. On the other hand, debugging runtime issues comes up way less often in Rust 🙃
In my opinion, these issues aren’t nearly as fundamental and annoying as the issues with exceptions. Now I can conclude that Rust has the best error handling! 💥
Related reading
Good articles that I haven’t hyperlinked anywhere else in the post:
- Checked exceptions: Java’s biggest mistake
- The Trouble with Checked Exceptions. A Conversation with Anders Hejlsberg, Part II
- Either vs Exception Handling
- The Error Model
My later post, diving deeper into the topic of error handling in Rust:
Discuss
Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-intensive Systems ↩︎
Get it? Exceptions stay unchecked! 🥁 ↩︎
Shout-out to u/WormRabbit who has pointed this out ! Originally, I missed this point and it wasn’t in the post. ↩︎
To be fair, the same result can be achieved in Java if you plan in advance and wrap all exceptions. You can throw exactly one checked exception, some base class. When you need to throw a new exception, you inherit a new child from that base class. This won’t break the callers, because they are already forced to handle the parent. And the callers also have type information and nice autogenerated docs about the possible children. Thanks to brabel for pointing this out .
Although, the Rust solution still seems cleaner and produces fewer types/objects/boilerplate, considering that defining and rethrowing wrapper exceptions is pretty verbose in Java. Also, when you decide to make a breaking change and mark an enum as
#[non_exhaustive]
, it doesn’t cause as much refactoring, compared to changing anything in thethrows
clause. ↩︎Actually, there are some workarounds, like using std::panic::catch_unwind or doing the work on a separate thread . That’s what all popular web frameworks do to avoid crashing the entire process when one of the requests panics. But the process still crashes if the target doesn’t support unwinding or the project is built with
panic = "abort"
setting. It also crashes when a destructor panics in an already-panicking thread . ↩︎E.g., most of the Rust Standard Library APIs panic on OOM conditions because it’s geared towards application programming and treats OOM as a situation that the application won’t attempt to handle anyway.
This is controversial because it doesn’t always provide equivalent non-panicking APIs for other use cases. It should accommodate low-level use cases better. But the existence of a convenient panicking API is OK. It’s more appropriate for most applications. A typical modern system with memory overcommitment will never report OOM on allocation anyway. ↩︎
There are “easier” alternative approaches that erase the type, like
Box<dyn Error>
. ↩︎Man, and third-party libraries are so good. But I promised not to get into detail with those… ↩︎
Comments