Simplify error handling

Consider the following code:

enum MyError {
    Io(io::Error),
    Parser(num::ParseIntError)
}

use crate::MyError::{Io, Parser};

impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> Self {
        Io(err)
    }
}

impl From<num::ParseIntError> for MyError {
    fn from(err: num::ParseIntError) -> Self {
        Parser(err)
    }
}

fn get_file(s: &str) -> Result<File, io::Error> {
    let file = File::open("sdasfsadas")?;
    Ok(file)
}

fn parse_int(file: &File) -> Result<u32, num::ParseIntError> {
    Ok(2u32)
}

fn handle_file(s: &str) -> Result<u32, MyError> {
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

As we can see there are lots of boilerplate code !!

Let's simplify it:

/// Auto generated code
/// enum MyError {
///     Io(io::Error),
///     Parser(num::ParseIntError)
/// }
/// 
/// use crate::MyError::{Io, Parser};
/// 
/// impl From<std::io::Error> for MyError {
///     fn from(err: std::io::Error) -> Self {
///         Io(err)
///     }
/// }
/// 
/// impl From<num::ParseIntError> for MyError {
///     fn from(err: num::ParseIntError) -> Self {
///         Parser(err)
///     }
/// }

fn get_file(s: &str) -> File throws { // Auto generated result Result<u32, io::Error>
    let file = File::open("sdasfsadas")?;
    Ok(file)
}

fn parse_int(file: &File) -> u32 throws num::ParseIntError { // Auto generated result Result<u32, num::ParseIntError>
    Ok(2u32)
}

fn handle_file(s: &str) -> u32 throws { // Auto generated result Result<u32, MyError> and enum MyError
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

It is possible also to specify errors manually:

fn get_file(s: &str) -> File throws io::Error { // Auto generated result Result<u32, io::Error>
    let file = File::open("sdasfsadas")?;
    Ok(file)
}

fn parse_int(file: &File) -> u32 throws num::ParseIntError { // Auto generated result Result<u32, num::ParseIntError>
    Ok(2u32)
}

fn handle_file(s: &str) -> u32 throws io::Error, num::ParseIntError { // Auto generated result Result<u32, MyError> and enum MyError
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

When user specify one error after throws we should specify all errors

Also we could specify our own custom enum error MyError after throws keyword:

fn handle_file(s: &str) -> u32 throws MyError { // Auto generated result Result<u32, MyError>
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

In this case we should specify MyError manually as previously

Okay, let's look how the caller will handle result of handle_file:

fn handle_file(s: &str) -> u32 throws MyError { // Auto generated result Result<u32, MyError>
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

fn main() {
    let res = handle_file("some.txt");
    match res {
      Ok(res_code) => println!("res_code is {}", res_code),
      Err(IoError(err)) => println!("io_err is {}", err),
      Err(NumParserIntError(err)) => println!("parse_err is {}", err),
    };
}

This topic relate to:

It is also possible to handle multiple errors without introducing a new keyword and syntax, but instead by adding anonymous enum types:

fn get_file(s: &str) -> Result<u32, io::Error> {
    let file = File::open("sdasfsadas")?;
    Ok(file)
}

fn parse_int(file: &File) -> Result<u32, num::ParseIntError> {
    Ok(2u32)
}

fn handle_file(s: &str) -> Result<u32, io::Error | num::ParseIntError> { // Where io::Error | num::ParseIntError is auto generated anonymuos enum  AnonymousEnumError
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}
4 Likes

You can use thiserror to get rid of most of the boiler plate, you only have to write out

#[derive(Error, Debug)]
enum MyError {
    #[error("io error")]
    Io(#[from] io::Error),
    #[error("integer parse error")]
    Parser(#[from] num::ParseIntError)
}

Which is pretty concise. (And it even generates a Display impl!)

11 Likes

Yeah, thanks !! It is cool, I have not know such way ... But anyway from my point of view it is more readable either to auto-generate this enum or to specify all errors after throws keyword

It will have the following advantages:

  1. More readable code
  2. It will help people from other languages

Also it is point to discuss, maybe better would be to use fail keyword in such scenario ...

1 Like

The advantage of using thiserror is;

  • now you can specify the same error type across many functions
  • it doesn't extend the language
  • it generates Display impls

Using the same error type across many functions is pretty common, and if you omit the error type when there are lots of different error conditions (like in your first example) it becomes much harder to reason about what errors a function can yield (this makes it easier to accidentally break something).

5 Likes

@RustyYato You wrote:

now you can specify the same error type across many functions

Compiler by itself could reuse in crate enums that have been generated and have the same structure !! Also it is possible to specify directly MyError after throws keyword and we will have the same usability

it doesn't extend the language

If we want to reduce boilerplate code it is necessary addition

it generates Display impls

General Result<T, E> also does not support Display impl !! It is not an issue

Yes, but I'm worried about functions that need to return 4+ error sources (io::Error, num::Parse*Error, some_lib::Error ...), let's say your function has many such functions, you would need to either 1. invest in a common error type, 2. use error inference. I don't think that error inference would be desirable because it makes code much harder to read. Global type inference is really bad for readability.

I think thiserror begs to differ. Minimal boilerplate without extending the language.

We would like to print/log errors when we encounter them. The fact that Result doesn't have a Display impl is irrelevant, because most of the time T won't be Display. (Note how std::error::Error requires Display)

1 Like

It's probably worth mentioning that thiserror is only one in a long line of error type management crates. AFAIK the best 10,000-foot summary of where we're at in this space is:

https://blog.yoshuawuyts.com/error-handling-survey/

As far as throws sugar, well, you linked to some of the past discussions yourself. There really is nothing new to say on that topic anymore :slight_smile:

5 Likes

I do not think that it will make code less readable ... The reason I think so, because when we will read documentation to function rustdoc will autogenerate all errors inferenced after throws keyword

Also as first stage it is possible to implement just first solution for autogenerating enum MyError

Let's allow first of all the following code:

fn handle_file(s: &str) -> u32 throws io::Error, num::ParseIntError { // Auto generated result Result<u32, MyError> and enum MyError
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

It is also acceptable from my point of view, because it do not inforce user to write custom enum MyError

And if user what to specify custom enum error, it would be still possible the following code:

fn handle_file(s: &str) -> u32 throws MyError { // Auto generated result Result<u32, MyError> and enum MyError
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

We could leave inference for the future if it will be needed

So many crates prove that Rust does not provide general syntax for handling multiple errors

Well... tautologically, yes? That doesn't automatically mean we need core language syntax for this. These crates' APIs are already awfully concise.

Admittedly you seem to be widening the scope of this thread to include not just error type management, but also most of if not all of Ok-wrapping, try/throws/etc sugar proposals, anonymous enums and enum impl Trait. All of which have been several threads of their own already, so I'll stop here until there's a more specific question.

4 Likes

You have completely missed the point. For small functions with 2 error types, yes it does seem better, but it does not scale. For example, if you need to add an error condition to a function, then you will need to change every function that calls that function, potentially breaking downstream crates. With a common error type, you just need to add a variant (and with #[non_exhaustive], it doesn't even have to be a breaking change).

I think that the throws syntax is actively harmful, because it makes it easier to do the lazy thing which could lead to breakage, and unmaintainable code in the future.

If throws is just an alias for Result, then there is no point to it.

2 Likes

throws keyword is an alias for Result<T, E> for generating E custom error such as MyError:

enum MyError {
    Io(io::Error),
    Parser(num::ParseIntError)
}

And it is needed to reduce boilerplate code

So far this sounds like an "anonymous enums" proposal. I believe https://github.com/rust-lang/rfcs/pull/2587 was the last serious attempt at something like this.

This is also following the weirdly standard pattern for such suggestions by forgetting to describe what matching on one of these autogenerated/anonymous enums would look like, which is usually where these proposals get bogged down in unpleasantness.

How would you write the client code calling one of these throws functions?

4 Likes

I agree with @RustyYato here. There are two different ways to implement this proposal, and both have serious flaws:

  1. Sweep all dependencies and accumulate all possible error values that can be returned from a function with the throws keyword, into a huge enum.

    That enum would have to be #[non_exhaustive], because downstream users could implicitly add a new error variant. As a result, you can't exhaustively pattern-match on this enum. Also you don't know which error variants can be returned from a function.

  2. Auto-generate one error enum for each function with the throws keyword and at least two error types. Functions with the same set of error types can share the same auto-generated error enum; conversions between error types are auto-implemented where necessary.

    This has the issues Yato talked about: It requires whole-program type inference, which makes it really difficult to avoid breaking changes for downstream users.

However, if you only allow the explicit version, this would be less problematic:

fn foo() -> u32 throws io::Error, num::ParseIntError; // okay
fn foo() -> u32 throws;                               // forbidden!

The only remaining problem is, how do we pattern match on this auto-generated type?

An alternative would be anonymous enums that automatically coerce into their supersets:

fn handle_file(s: &str) -> Result<u32, io::Error | num::ParseIntError> {
    let content = std::fs::read_to_string(s)?;
    let int: u32 = content.parse()?;
    Ok(int)
}

match handle_file("") {
    Ok(n) => n,
    Err(e @ io::Error) => todo!(),
    Err(e @ num::ParseIntError) => todo!(),
}

Anonymous enums have been proposed before.

2 Likes

Okay, lets look at the following example:

fn handle_file(s: &str) -> u32 throws io::Error, num::ParseIntError { // Auto generated result Result<u32, MyError>
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

fn main() {
    let res = handle_file("some.txt");
    match res {
      Ok(res_code) => println!("res_code is {}", res_code),
      Err(IoError(err)) => println!("io_err is {}", err),
      Err(NumParseIntError(err)) => println!("parse_err is {}", err),
    };
}

or the following example:

fn handle_file(s: &str) -> u32 throws MyError { // Auto generated result Result<u32, MyError>
    let file = get_file(s)?;
    let int = parse_int(&file)?;
    Ok(int)
}

fn main() {
    let res = handle_file("some.txt");
    match res {
      Ok(res_code) => println!("res_code is {}", res_code),
      Err(IoError(err)) => println!("io_err is {}", err),
      Err(NumParseIntError(err)) => println!("parse_err is {}", err),
    };
}

Where do the names Io or Parser come from? That's the main issue.

2 Likes

Please, take a look at the begining of post io::Error goes from get_file and num::ParseIntError goes from parse_int functions

Under cover Rust will generate enum MyError which will contain this errors:

enum MyError {
    // IoError generated field based on module and error type
    IoError(io::Error),
    // NumParseIntError generated field based on module and error type
    NumParseIntError(num::ParseIntError)
}

I like this idea of anonymous enum !!

If we would have such syntax it would be possible to handle multiple errors without introducing a new keyword throws !!

2 Likes

We had people trying to come up with a coherent solution for over 4 years and at least with 3 serious efforts if i remember correcly. This problem is just not as easy as one might think at the beginning.

1 Like

Depending on what you're looking for, you could also use anyhow::Result<u32>, which will wrap any error you like and not require creating any types. You can still get a specific error type out of it if you need to (using downcasting), and in the common case where all errors just get reported without being matched on, you don't need to do anything at all.

Might that give you the reduced boilerplate you're looking for?

2 Likes