#try #result #error-handling

yanked errable

Errable is an Option with inverted Try-semantics

0.1.0 Jan 10, 2023

#73 in #result

MIT license

22KB
329 lines

Errable

Errable is an Option with inverted Try-semantics.

What this means is that using the ? operator on a Errable<E> will exit early if an error E is contained within, or instead act as a no-op, if the value is Success.

This is in contrast to Option where using ? on a None-value will exit early.

Errable fills the gap left by the Result and Option types:

Potential Success Potential Failure
Result<T , E>
Option<T> Errable<E>

Example

This code illustrates how Errable can be used to write succint validation code which exits early in case of failure.

use errable::Errable::{self, Fail, Success};

// Validates the input number `n`, returning a `Fail`
// if the input number is zero, or `Success` otherwise.
fn fails_if_number_is_zero(n: u32) -> Errable<&'static str> {
    if n == 0 {
        Fail("number is zero")
    } else {
        Success
    }
};

// Check many numbers, returning early if a tested
// number is equal to zero.
fn check_many_numbers() -> Errable<&'static str> {
    fails_if_number_is_zero(1)?;
    fails_if_number_is_zero(3)?;
    fails_if_number_is_zero(0)?; // <--- Will cause early exit

    // Following lines are never reached
    fails_if_number_is_zero(10)?;
    
    Success
}

assert_eq!(check_many_numbers(), Errable::Fail("number is zero"));

Motivation

Errable fills the gap left by Option and Result and clearly conveys intent and potential outcomes of a function.

A function which returns Errable has only two potential outcomes, it can fail with an error E, or it can succeed.

Why not Result?

Because Result implies output. Take std::fs::rename for instance:

If I told you that the return type of rename was a Result<T, E>, what would you guess T and E to be?

You might rightly assume that E was std::io::Error, but what about T? It could reasonably return any number of things:

  • The canonical path of the destination of the renamed file.
  • The size of the moved file.
  • The size of the file (if any) replaced by the renamed file.
  • Or perhaps even a handle to the overwritten file.

Of course none of these are true, as the T value of rename is the unit value (). rename never produces any output, it can only signal errors. So why not signal that clearly to the user?

I would argue that using a type which signals the potential for failure, but no output upon success would more clearly express the intent and potential outcomes when using this function.

Why not Option?

Potential failure could be expressed using an Option<E>, but as stated above, the Try-semantics of Option makes it unergonomic to work with:

type Error = &'static str;

fn fails_if_number_is_zero(n: u32) -> Option<Error> {
    if n == 0 {
        Some("number is zero")
    } else {
        None
    }
};

fn check_many_numbers() -> Option<Error> {
    // We have to explicitly check, since using `?` here would result in an early exit,
    // if the call returned None, which is the opposite of what we intend.
    if let Some(err) = fails_if_number_is_zero(1) {
        return Some(err)
    }

    // .. Repeating the above three lines for each check is tedious compared to
    // just using the `?` operator, as in the example.

    None
}

Conversion from Result

Switching from using Result to Errable is very simple, as illustrated with this before/after example:

fn validate_number(x: u32) -> Result<(), &'static str> {
    match x {
        0 ..= 9 => Err("number is too small"),
        10..=30 => Ok(()),
        31..    => Err("number is too large")
    }
}

Using Errable:

fn validate_number(x: u32) -> Errable<&'static str> {
    match x {
        0 ..= 9 => Fail("number is too small"),
        10..=30 => Success,
        31..    => Fail("number is too large")
    }
}

Compatibility

Errable contains utility functions for mapping to and from Result and Option, as well as FromResidual implementations for automatically performing these conversions when used with the ? operator.

fn fails_if_true(should_fail: bool) -> Errable<&'static str> {
    if should_fail {
        Fail("Darn it!")
    } else {
        Success
    }
}

fn try_producing_value() -> Result<u32, &'static str> {
    fails_if_true(false)?;
    fails_if_true(true)?;

    Ok(10)
}

No runtime deps