A guide to error handling in Rust
nrc.github.io
nrc.github.io
The key trick of Try is that it converts something (by default an Option or a Result or async Polls of those types) into a ControlFlow†. This is the one nice trick about Exceptions in languages which have them - they influence control flow, but Rust reified it as a vocabulary type which I think is much better. We can pass this thing back to somebody who cares about the resulting control flow, not just suddenly wrench the control flow out from under the rest of the software.
† unlike Try, ControlFlow is actually a stable type you can use today in your Rust and, like std::cmp::Ordering it's useful even just as a vocabulary type, disregarding its semantics. Library A and Library B, written by different people, in different circumstances, both agree that ControlFlow::Continue is continue and ControlFlow::Break is break whereas who knows what the boolean false from Library A means to Library B, let alone what if anything Library B's custom type BPartialResult means to Library A's code.
Error handling has gone from uber painful in 2018 to pretty decent in the latest editions.
`Try` was recently changed significantly with the introduction of `ControlFlow`. IMO it's a big improvement.
One gotcha that I hit was using ? in sample code in documentation. It didn't work, so I had to replace all of my ? with .unwrap().
I generally consider .unwrap() a poor example, because it encourages writing code that could crash a program unnecessarily.
https://doc.rust-lang.org/std/convert/trait.From.html#exampl...
they talk about it here:
https://nrc.github.io/error-docs/error-design/error-type-des...
but including more than a snippet would go a long way to that "aha" moment I think. This was frustrating for me browsing this site. The author wrote 10 pages of docs, but nearly all the examples are like 5 line snippets of code. I think examples are equally important as the discussion itself. Rust itself suffers from the same problem:
The author might not have included that as they call out you likely shouldn't directly wrap another error.
I go a step further and think that public errors shouldn't have From's for concrete types, exposing your implementation details, and that enum errors are more generally too tied to implementation details to be used in libraries.
> enum errors are more generally too tied to implementation details to be used in libraries
I generally agree. SNAFU addresses these problems in two ways:
1. The `From` implementation is not created for the underlying error but for an intermediate type (by default). That type is private to the crate (by default) and cannot expose implementation details.
2. There's an opaque error facility to completely hide the enum details.
Put together, that looks something like...
use snafu::prelude::*;
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Snafu)]
enum ErrorImpl {
#[snafu(display("Could not read the config file {}", path.display()))]
UnableToReadConfig {
source: std::io::Error,
path: PathBuf,
},
#[snafu(display("Could not write the config file {}", path.display()))]
UnableToWriteConfig {
source: std::io::Error,
path: PathBuf,
},
}
#[derive(Debug, Snafu)]
pub struct Error(ErrorImpl);
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub fn do_stuff_with_config(path: &Path) -> Result<()> {
let config = fs::read_to_string(path).context(UnableToReadConfigSnafu { path })?;
fs::write(path, config).context(UnableToWriteConfigSnafu { path })?;
Ok(())
}
Other things about SNAFU:- It's very easy to add valuable context to the errors. See how the `&Path` context is transformed to a `PathBuf` with low ceremony in the example.
- You can create struct- or enum-based errors.
- You can use "stringly-typed" errors (akin to anyhow) but in combination with strongly-typed errors. This allows you to start out with a loose error handling regimen and make it stronger as you go along.
- There's support for capturing backtraces or lightweight file/line/column information.
- There's a pretty error reporter for usage with `main` functions or tests.
- There's support for the nightly-only Provider API.
Without them, using typed errors is very clumsy. Optimally, you would write the following code:
fn foo(r1: Result<i32, Error1>, r: Result<i32, Error2>) {
let i1 = r1?;
let i2 = r2?;
// ...
}
and Rust would infer the return type to be Result<String, Error1 | Error2>
without having to do any extra definitions or conversions.That would make precise error handling on libraries quite a bit better.
Of course, if you care about which error is from which function, you can always easily do that by wrapping them into a sumtype, but in practice this is a rather rare use-case in application code at least.
But otherwise we can. And this is something that we will know at compile-time, so we can prevent runtime-checks that would not work.
But essentially, when it comes to union types, they behave like sets. The compiler merges them. (A | B) | (A | B) is the same as A | B. But for sum types (even anonymous ones such as tuples) the compiler can't merge them because that would lose information (if the result is from the first A | B or the second one). Instead, you end up with a nested structure.
Which one is desired depends on the use-case, but it's definitely different.
let x: u64|i32 = ...
It's impossible for the compiler to do anything with x without adding some kind of runtime type tag. The representations of those types are different.But with something like OCaml's polymorphic variants, you could do e.g.
let x: Big(u64)|Small(i32) = ...
and then switch on the tag (Big or Small) to determine what to do with the values.Yes, in this particular case that's true. If the developer tries to do anything except maybe printing it for debug then the compiler will tell the developer so and the developer will have to switch to sumtypes / wrap it just like it is done in OCaml by default.
That sounds like trait objects/dynamic dispatch/`dyn`, which comes with runtime costs.
> or have to pattern match later and have a way to tell them apart.
That "way to tell them apart" is a tag, which would make it a tagged union/enum, not an untagged union. Those already exist, though not in an anonymous flavour.
> That "way to tell them apart" is a tag, which would make it a tagged union/enum, not an untagged union.
No. The difference is that a tagged union (sum type) is defined in advance but generally a union is not necessarily tagged but _can_ be tagged.
Example: say you have tagged union with 3 different types / tags A, B and C. You can now define an adhoc/untagged union that is A | C. That means, we can guarantee at compile time, that we will be able to tell A and C apart later. But it is still not the same, because the combination of A and C was decided adhoc and was not predefined by the developer anywhere necessarily - which is what makes it different from A, B, C which where specifically defined by the developer.
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("One")]
One(#[from] Error1),
#[error("Two")]
Two(#[from] Error2),
}
fn foo(r1: Result<i32, Error1>, r2: Result<i32, Error2>) -> Result<..., Error> {
let i1 = r1?;
let i2 = r2?;
// ...
}
Whereas for binaries, people usually recommend anyhow. Code sample[1]: fn foo(r1: Result<i32, Error1>, r2: Result<i32, Error2>) -> anyhow::Result<...> {
let i1 = r1?;
let i2 = r2?;
// ...
}
[0]: https://play.rust-lang.org/?version=stable&mode=debug&editio...
[1]: https://play.rust-lang.org/?version=stable&mode=debug&editio... fn read_string() -> std::io::Result<String> {
Ok("123".to_owned())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let s = read_string()?; // io::Error
let n = i32::from_str_radix(&s, 10)?; // num::ParseIntError
println!("read number: {n}");
Ok(())
}The pattern I use in my app is to use thiserror, and then just have an anyhow catch-all. That lets me do specific stuff where I know I'm going to need specific handling, and an easy-to-use fallback for just saying "this bad thing happened" with the anyhow! macro.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Not logged in")]
NotLoggedIn,
#[error(transparent)]
Api(#[from] ApiError),
// etc
#[error(transparent)]
Other(#[from] anyhow::Error),
}
I don't know if this is the best pattern but it's worked really well for me.The return type is part of the function signature and Rust deliberately doesn't infer signatures, in languages with "too much" inference it's impractical for the human programmer to keep track of types because it's all inferred, this has started to be a problem in C++ as more and more things are auto. Rust has some very sophisticated inference inside a function (including partial inference and inferring types from how they're later used), but none for the signature.
While codeblocks `fn1(fn2(), fn3)` and `var r1 = fn2(); var r2 = fn3; fn1(r1, r2)` are more or less identical, unless you have static type definitions for these methods you start having a very bad time inferring what types are being passed around.
Consider typical python wrapper library with liberal use of *kwargs to pass non-wrapped arguments down to wrappee. Those arguments and their types (as much as they are available in python, you get the idea) are entirely missing from wrapper code and make changes at call site pretty difficult
It doesn't matter if the types are annotated explicitly or inferred. The amount of information is 100% the same. The IDE could just fill in the types _exactly_ in the same way as they would look like when annotated by hand - maybe just with a different color.
IntelliJ does this quite well, see for instance: https://i.stack.imgur.com/tiqjc.png
This is not a guess. This is the real types. There is literally no difference in the behaviour/semantics of the code.
> Consider typical python wrapper library with liberal
No, because python is not statically typed. You can't compare that.
Explicit offers the opportunity for narrowing, comments, and sometimes choice of names. Otherwise, yes.
[1]: https://play.rust-lang.org/?version=stable&mode=debug&editio...
Allowing _ in return position only for private functions feels like something that ends up surprising 99% of people either that this works, or that it doesn't work for their public function and neither group of people are filled with joy as a result.
It's not a hill I'd die on, but it would go on my list of things I don't like in Rust, along with most as casts, impl AddAssign for String, is_ascii_predicates(/* taking */ &self)
let a = if cond {
1
} else {
1.0
};
a + 3
Now, the error would be pushed to the `+` operator because there isn't an `Add` for `f32 | u32`. Granted, this is a trivial example, and a programmer can easily see through it, but in general this can get very overwhelming and cause errors to leave their 'root cause'.Why not? It makes total sense for one to exist. This is something the language needs to deal with, not the programmer. But the programmer always sprinkle annotations so that errors can only reach so far.
I disagree: IME, when you add a float and an integer, you want to cast float to integer 50% of the time, and integer to float the remaining 50% of the time.
Even if it leads to more verbosity, I prefer arithmetic operations to be endomorphisms and use explicit casts.
let x=19/10+0.5
I want to decide for myself if my result is 2.4, 1.5, 1, or 2 in the example aboveBut also, for every developer with a couple of month experience, this is not a new problem. It happens all the time, be it missing some parens or a dot or a return / semicolon (depending on the language of course).
Usually it's very easy to hunt it down - you see "a + 1" and you say "wait, a should be an integer - why is it not" and then you go back from there. But in general I agree that it adds some time in such cases that would be resolved quicker with annotations everywhere.
class A {
x = 10;
scale(n: number): void {
this.x *= n;
}
}
class B {
y = 10;
scale(n: number): void {
this.y *= n;
}
}
var z: A | B;
z = new A
z.scale(2);
z = new B
z.scale(2);
I was also looking for Sum Types/anonymous enums[0].[0] https://news.ycombinator.com/threads?id=karmakaze&next=33505...
Of course this is just a minimal example... Others have talked about the real point of this comment, and it's just a minor nit. I wanted to point this out for new rust programmers reading this.
A myriad of experimental prototypes (like the failure crate and its descendants) have been made, experimented with and then retired and looks like the progress is converging to these two complementary error handling crates (anyhow, thiserror, and a few mostly-compatible variants like eyre), and work going on to standardize some aspects of it so (parts of) these crates can be retired. There's also core::error that's bringing this to no-std environments.
So yeah, it definitely was not great on day 1 and there's been a lot of churn on error handling but it is going in the right direction.
There is disadvantages for going "catch all" libraries as then you can't be sure you are catching all errors.
Try blocks let you do what ? (the Try operator) does within a block, rather than needing to split out a separate function for it, which makes sense because why should functions be special in this way?