ergonomic and precise error handling built atop type-level set arithmetic

Related tags

Command-line terrors
Overview

terrors - the Rust error handling library

Handling errors means taking a set of possible error types, removing the ones that are locally addressible, and then if the set of errors is not within those local concerns, propagating the remainder to a caller. The caller should not receive the local errors of the callee.

Principles

  • Error types should be precise.
    • terrors::OneOf solves this by making precise sets of possible errors:
      • low friction to specify
      • low friction to narrow by specific error handlers
      • low friction to broaden to pass up the stack
  • Error handling should follow the single responsibility principle
    • if every error in a system is spread everywhere else, there is no clear responsibility for where it needs to be handled.
  • No macros.
    • Users should not have to learn some new DSL for error handling that every macro entails.

Examples

use terrors::OneOf;

let one_of_3: OneOf<(String, u32, Vec<u8>)> = OneOf::new(5);

let narrowed_res: Result<u32, OneOf<(String, Vec<u8>)>> =
    one_of_3.narrow();

assert_eq!(5, narrowed_res.unwrap());

OneOf can also be broadened to a superset, checked at compile-time.

use terrors::OneOf;

struct Timeout;
struct AllocationFailure;
struct RetriesExhausted;

fn allocate_box() -> Result<Box<u8>, OneOf<(AllocationFailure,)>> {
    Err(AllocationFailure.into())
}

fn send() -> Result<(), OneOf<(Timeout,)>> {
    Err(Timeout.into())
}

fn allocate_and_send() -> Result<(), OneOf<(AllocationFailure, Timeout)>> {
    let boxed_byte: Box<u8> = allocate_box().map_err(OneOf::broaden)?;
    send().map_err(OneOf::broaden)?;

    Ok(())
}

fn retry() -> Result<(), OneOf<(AllocationFailure, RetriesExhausted)>> {
    for _ in 0..3 {
        let Err(err) = allocate_and_send() else {
            return Ok(());
        };

        // keep retrying if we have a Timeout,
        // but punt allocation issues to caller.
        match err.narrow::<Timeout, _>() {
            Ok(_timeout) => {},
            Err(one_of_others) => return Err(one_of_others.broaden()),
        }
    }

    Err(OneOf::new(RetriesExhausted))
}

OneOf also implements Clone, Debug, Display, and/or std::error::Error if all types in the type set do as well:

use std::error::Error;
use std::io;
use terrors::OneOf;

let o_1: OneOf<(u32, String)> = OneOf::new(5_u32);

// Debug is implemented if all types in the type set implement Debug
dbg!(&o_1);

// Display is implemented if all types in the type set implement Display
println!("{}", o_1);

let cloned = o_1.clone();

type E = io::Error;
let e = io::Error::new(io::ErrorKind::Other, "wuaaaaahhhzzaaaaaaaa");

let o_2: OneOf<(E,)> = OneOf::new(e);

// std::error::Error is implemented if all types in the type set implement it
dbg!(o_2.description());

OneOf can also be turned into an owned or referenced enum form:

use terrors::{OneOf, E2};

let o_1: OneOf<(u32, String)> = OneOf::new(5_u32);

match o_1.as_enum() {
    E2::A(u) => {
        println!("handling reference {u}: u32")
    }
    E2::B(s) => {
        println!("handling reference {s}: String")
    }
}

match o_1.to_enum() {
    E2::A(u) => {
        println!("handling owned {u}: u32")
    }
    E2::B(s) => {
        println!("handling owned {s}: String")
    }
}

Motivation

The paper Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-intensive Systems is goldmine of fascinating statistics that illuminate the software patterns that tend to correspond to system failures. This is one of my favorites:

almost all (92%) of the catastrophic system failures
are the result of incorrect handling of non-fatal errors
explicitly signaled in software.

Our systems are falling over because we aren't handling our errors. We're doing fine when it comes to signalling their existence, but we need to actually handle them.

When we write Rust, we tend to encounter a variety of different error types. Sometimes we need to put multiple possible errors into a container that is then returned from a function, where the caller or a transitive caller is expected to handle the specific problem that arose.

As we grow a codebase, more of these situations pop up. While it's not so much effort to write custom enums in one or two places that hold the precise set of possible errors, most people resort to one of two strategies for minimizing the effort that goes into propagating their error types:

  • A large top-level enum that holds variants for errors originating across the codebase, tending to grow larger and larger over time, undermining the ability to use exhaustive pattern matching to confidently ensure that local concerns are not bubbling up the stack.
  • A boxed trait that is easy to convert errors into, but then hides information about what may actually be inside. You don't know where it's been or where it's going.

As the number of different source error types that these error containers hold increases, the amount of information that the container communicates to people who encounter it decreases. It becomes increasingly unclear what the error container actually holds. As the precision of the type goes down, so does a human's ability to reason about where the appropriate place is to handle any particular concern within it.

We have to increase the precision in our error types.

People don't write a precise enum for every function that may only return some subset of errors because we would end up with a ton of small enum types that only get used in one or two places. This is the pain that drives people to using overly-broad error enums or overly-smooth boxed dynamic error traits, reducing their ability to handle their errors.

Cool stuff

This crate is built around OneOf, which functions as a form of anonymous enum that can be narrowed in ways that may be familiar for users of TypeScript etc... Our error containers need to get smaller as individual errors are peeled off and handled, leaving the reduced remainder of possible error types if the local concerns are not present.

The cool thing about it is that it is built on top of a type-level heterogenous set of possible error types, where there's only one actual value among the different possibilities.

Rather than having a giant ball of mud enum or boxed trait object that is never clear what it actually contains, causing you to never handle individual concerns from, the idea of this is that you can have a minimized set of actual error types that may thread through the stack.

The nice thing about this type-level set of possibilities is that any specific type can be peeled off while narrowing the rest of the types if the narrowing fails. Both narrowing and broadening are based on compile-time error type set checking.

The Trade-Off

Type-level programming is something that I have tried hard to avoid for most of my career due to confusing error messages resulting from compilation errors. These complex type checking failures produce errors that are challenging to reason about, and can often take several minutes to understand.

I have tried hard to avoid exposing users of terrors to too many of the sharp edges in the underlying type machinery, but it is likely that if the source and destination type sets do not satisfy the SupersetOf trait in the right direction depending on whether narrow or broaden is being called, that the error will not be particularly pleasant to read. Just know that errors pretty much always mean that the superset relationship does not hold as required.

Going forward, I believe most of the required traits can be implemented in ways that expose users to errors that look more like (A, B) does not implement SupersetOf<(C, D), _> instead of Cons<A, Cons<B, End>> does not implement SupersetOf<Cons<C, Cons<D, End>>> by leaning into the bidirectional type mapping that exists between the heterogenous type set Cons chains and more human-friendly type tuples.

Special Thanks

Much of the fancy type-level logic for reasoning about sets of error types was directly inspired by frunk. I had been wondering for years about the feasibility of a data structure like OneOf, and had often assumed it was impossible, until I finally had an extended weekend to give it a deep dive. After many false starts, I finally came across an article written by lloydmeta (the author of frunk) about how frunk handles several related concerns in the context of a heterogenous list structure. Despite having used Rust for over 10 years, that article taught me a huge amount about how the language's type system can be used in interesting ways that addressed very practical needs. In particular, the general perspective in that blog post about how you can implement traits in a recursive way that is familiar from other functional languages was the missing primitive for working with Rust that I had not realized was possible for my first decade with the language. Thank you very much for creating frunk and telling the world about how you did it!

Comments
  • Avoid unidiomatic one-element tuples

    Avoid unidiomatic one-element tuples

    Documentation examples and tests contain OneOf<(T,)>-shaped return values:

    • fn allocate_box() -> Result<Box<u8>, OneOf<(AllocationFailure,)>>
    • fn send() -> Result<(), OneOf<(Timeout,)>>
    • fn allocates() -> Result<(), OneOf<(NotEnoughMemory,)>>

    You can find all of them using this regex: OneOf<\(\w+,\)>

    I'm heavily against using such examples in documentation. No real-world code looks like this. It's always going to be Result<_, T> rather than Result<_, OneOf<(T,)>>. The current examples may falsely imply that the library is unergonomic and requires returning OneOf<(T,)> instead of T.

    If you want to showcase broadening, I suggest using real-world patterns like T -> OneOf<(T, U)> or OneOf<(T, U)> -> OneOf<(T, U, V)>, instead of OneOf<(T,)> -> OneOf<(T, U)>. In tests, we can keep that case if you want to cover it, but I'd add a comment that it is unidiomatic and simply covers an edge case.

    If you agree in principle, let me know and I'll implement these changes myself.

    opened by Expurple 2
  • Add CHANGELOG.md

    Add CHANGELOG.md

    Even though this is a new project, keeping up with its releases and navigating the history is already unnecessarily hard. At least for me.

    I summed up the history in a basic changelog. I didn't want to clutter it (and spend much time on it), so it doesn't include changes in implementation traits, documentation or tests. Only "notable" changes to OneOf. I hope, you don't mind maintaining it. Feel free to make edits before merging.

    To simplify the process, I also added tags like 0.3.0 on commits that modify the version in Cargo.toml. But it turns out that I can't move them over a pull request. Consider adding tags yourself, this sloudn't take much time. This simplifies git log navigation and also allows Github to autogenerate releases.

    opened by Expurple 1
  • Consider mentioning the license more prominently

    Consider mentioning the license more prominently

    I see that you've set license = "MIT OR Apache-2.0" in your Cargo.toml. You should also include the LICENSE files and mention the license in the README, so that people can confidently experiment with your code. I want to open a pull request, but I can't be sure if I'm allowed to do that

    opened by Expurple 1
  • OneOf is not Send

    OneOf is not Send

    We tried and failed to use OneOf on a separate thread because OneOf types don't appear to be send...

    fn foo() -> impl Send { OneOf::new(()) }

    results in:

    error[E0277]: (dyn Any + 'static) cannot be sent between threads safely --> otiv3/physical_controls/physical_controls.rs:155:13 | 155 | fn foo() -> impl Send { | ^^^^^^^^^ (dyn Any + 'static) cannot be sent between threads safely | = help: the trait Send is not implemented for (dyn Any + 'static), which is required by OneOf<_>: Send = note: required for Unique<(dyn Any + 'static)> to implement Send note: required because it appears within the type Box<(dyn Any + 'static)> --> /rustc/129f3b9964af4d4a709d1383930ade12dfe7c081/library/alloc/src/boxed.rs:196:12 note: required because it appears within the type OneOf<_> --> external/otiv3_crate_index__terrors-0.3.0/src/one_of.rs:32:12

    Thanks for the interesting library!

    opened by andrew-otiv 0
  • InterOp with anyhow crate

    InterOp with anyhow crate

    I am investigating migrating a large library / binary crate that uses thiserror internally and anyhow at the cli boundary.

    Unfortunately this doesn't compile:

    use terrors::OneOf;
    
    fn main() -> anyhow::Result<()> {
        test()?;
        Ok(())
    }
    
    fn test() -> Result<(), OneOf<(String,)>> {
        Ok(())
    }
    

    The compilation error is:

    error[E0277]: the trait bound `terrors::End: std::error::Error` is not satisfied
     --> test-anyhow/src/main.rs:4:11
      |
    4 |     test()?;
      |           ^ the trait `std::error::Error` is not implemented for `terrors::End`, which is required by `Result<(), anyhow::Error>: FromResidual<Result<Infallible, OneOf<(String,)>>>`
      |
      = help: the following other types implement trait `FromResidual<R>`:
                <Result<T, F> as FromResidual<Yeet<E>>>
                <Result<T, F> as FromResidual<Result<Infallible, E>>>
      = note: required for `Cons<String, terrors::End>` to implement `std::error::Error`
      = note: 1 redundant requirement hidden
      = note: required for `OneOf<(String,)>` to implement `std::error::Error`
      = note: required for `anyhow::Error` to implement `From<OneOf<(String,)>>`
      = note: required for `Result<(), anyhow::Error>` to implement `FromResidual<Result<Infallible, OneOf<(String,)>>>`
    
    error[E0277]: `(dyn Any + 'static)` cannot be sent between threads safely
       --> test-anyhow/src/main.rs:4:11
        |
    4   |     test()?;
        |           ^ `(dyn Any + 'static)` cannot be sent between threads safely
        |
        = help: the trait `Send` is not implemented for `(dyn Any + 'static)`, which is required by `Result<(), anyhow::Error>: FromResidual<Result<Infallible, OneOf<(String,)>>>`
        = help: the following other types implement trait `FromResidual<R>`:
                  <Result<T, F> as FromResidual<Yeet<E>>>
                  <Result<T, F> as FromResidual<Result<Infallible, E>>>
        = note: required for `Unique<(dyn Any + 'static)>` to implement `Send`
    note: required because it appears within the type `Box<(dyn Any + 'static)>`
       --> /nix/store/36813l3qgxqj6krm48099sqr3fv0j5yy-rust-default-1.78.0/lib/rustlib/src/rust/library/alloc/src/boxed.rs:197:12
        |
    197 | pub struct Box<
        |            ^^^
    note: required because it appears within the type `OneOf<(String,)>`
       --> /Users/jcarter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/terrors-0.3.0/src/one_of.rs:32:12
        |
    32  | pub struct OneOf<E: TypeSet> {
        |            ^^^^^
        = note: required for `anyhow::Error` to implement `From<OneOf<(String,)>>`
        = note: required for `Result<(), anyhow::Error>` to implement `FromResidual<Result<Infallible, OneOf<(String,)>>>`
    

    There seems to be 2 issues. std::error::Error is not implemented for End and the issue with thread safety. Would appreciate any ideas / comments for moving towards interoperability.

    opened by bodymindarts 2
  • Support implicit broadening using the try operator

    Support implicit broadening using the try operator

    Taking the example:

    use terrors::OneOf;
    
    struct Timeout;
    struct AllocationFailure;
    
    fn allocate_box() -> Result<Box<u8>, OneOf<(AllocationFailure,)>> {
        Err(AllocationFailure.into())
    }
    
    fn send() -> Result<(), OneOf<(Timeout,)>> {
        Err(Timeout.into())
    }
    
    fn allocate_and_send() -> Result<(), OneOf<(AllocationFailure, Timeout)>> {
        let boxed_byte: Box<u8> = allocate_box().map_err(OneOf::broaden)?;
        send().map_err(OneOf::broaden)?;
    
        Ok(())
    }
    
    // ...
    

    Instead of using allocate_box().map_err(OneOf::broaden)?, we could implement Into for any subset of a OneOf so this can be simplified to just allocate_box()?, and the ? operator will implicitly broaden the error.

    opened by Zk2u 0
  • Allow storing non-static references

    Allow storing non-static references

    This test doesn't compile, because constructors expect a 'static value:

    #[test]
    fn non_static_variants_should_compile() {
        let local_value = 42;
        // Create a scope to explicitly ensure that `_one_of_new` and `_one_of_from`
        // don't outlive `local_value` and are allowed to reference it.
        {
            let _one_of_new = OneOf::<(&i32,)>::new(&local_value);
            let _one_of_from = OneOf::<(&i32,)>::from(&local_value);
        }
    }
    

    We should probably make it compile, if we want OneOf to be competitive with regular named enums. I tried to fix this myself, but it takes more time than I expected. My type-foo isn't strong enough. I may try again in the future

    opened by Expurple 0
  • What to do with duplicate types in variants?

    What to do with duplicate types in variants?

    Currently, instantiations of OneOf with duplicated variants like OneOf<(bool, bool)> typecheck:

    fn this_code_compiles() -> OneOf<(bool, bool)> {
        unimplemented!()
    }
    

    but their constructors don't*, so there's no way to construct and actually use instances of these types.

    The user experience can be refined and guided in one of two ways:

    • Stay isomorphic to enums and allow constructing instances of these types in unambiguous ways. Maybe, using something like OneOf::from_enum?
    • Explicitly limit ourselves to type sets, document this and adjust the implementation so that the example above doesn't compile.

    Having the first option may be useful for some people, while others may find the code confusing. I can imagine easier interoperability with regular enums as one of the use cases.

    The second option is how anonymous unions usually work in other languages (at least in Python).

    I expect this topic to be discussed here for a while before landing on a decision.


    * OneOf::<(bool, bool)>::new(true) rightfully causes the error below. I wonder if it's already possible to follow the compiler suggestion and fix the error by adding some obscure annotation in place of ... in OneOf::<(bool, bool)>::new::<bool, ...>(true).

    error[E0283]: type annotations needed
       --> tests/usability.rs:13:5
        |
    13  |     OneOf::<(bool, bool)>::new(true)
        |     ^^^^^^^^^^^^^^^^^^^^^^^^^^ cannot infer type of the type parameter `Index` declared on the associated function `new`
        |
        = note: multiple `impl`s satisfying `Cons<bool, Cons<bool, terrors::End>>: terrors::type_set::Contains<bool, _>` found in the `terrors` crate:
                - impl<T, Index, Head, Tail> terrors::type_set::Contains<T, Cons<Index, ()>> for Cons<Head, Tail>
                  where Tail: terrors::type_set::Contains<T, Index>;
                - impl<T, Tail> terrors::type_set::Contains<T, terrors::End> for Cons<T, Tail>;
    note: required by a bound in `OneOf::<E>::new`
       --> /home/dima/code/oss/terrors/src/one_of.rs:109:22
        |
    106 |     pub fn new<T, Index>(t: T) -> OneOf<E>
        |            --- required by a bound in this associated function
    ...
    109 |         E::Variants: Contains<T, Index>,
        |                      ^^^^^^^^^^^^^^^^^^ required by this bound in `OneOf::<E>::new`
    help: consider specifying the generic arguments
        |
    13  |     OneOf::<(bool, bool)>::new::<bool, Index>(true)
        |                               +++++++++++++++
    

    Attempting to broaden OneOf::<(bool,)> into OneOf::<(bool, bool)> causes a similar error.

    opened by Expurple 0
Owner
Komora
Komora
Application microframework with command-line option parsing, configuration, error handling, logging, and shell interactions

Abscissa is a microframework for building Rust applications (either CLI tools or network/web services), aiming to provide a large number of features w

iqlusion 524 Dec 26, 2022
Rust CLI utility library. Error handling, status reporting, and exit codes.

narrate This library provides CLI application error and status reporting utilities. The coloured output formatting aims to be similar to Cargo. Error

Christopher Morton 5 Nov 2, 2022
Bam Error Stats Tool (best): analysis of error types in aligned reads.

best Bam Error Stats Tool (best): analysis of error types in aligned reads. best is used to assess the quality of reads after aligning them to a refer

Google 54 Jan 3, 2023
This library provides a convenient derive macro for the standard library's std::error::Error trait.

derive(Error) This library provides a convenient derive macro for the standard library's std::error::Error trait. [dependencies] therror = "1.0" Compi

Sebastian Thiel 5 Oct 23, 2023
(Pre-Release Software) Secure, Encrypted, P2P chat written atop Warp, IPFS, LibP2P, Dioxus and many more awesome projects and protocols.

Uplink Privacy First, Modular, P2P messaging client built atop Warp. Uplink is written in pure Rust with a UI in Dioxus (which is also written in Rust

Satellite 13 Jan 25, 2023
SP3 Precise GNSS Orbit and Clock parser :artificial_satellite:

SP3 SP3 Precise GNSS Orbit files parser. SP3 is specifid by IGS. The parser only supports Revisions C & D at the moment and rejects revisions A & B. G

gwbres 4 Aug 31, 2023
A high-level, ergonomic crate for interacting with the UploadThing API

utapi-rs A high-level, ergonomic Rust crate for interacting with the Uploadthing API. Why? If you're using Rust and want to use Uploadthing for file u

Ivan Leon 4 Feb 2, 2024
Need a powerful and simple library to work with arithmetic progressions in Rust? You should definitively try out ariprog!

Ariprog I had a test (03/2024) on arithmetic progressions, so I decided to create a library to study math. Taking advantage of the fact that I was stu

KauĂȘ Fraga Rodrigues 4 Mar 19, 2024
Given a set of kmers (fasta format) and a set of sequences (fasta format), this tool will extract the sequences containing the kmers.

Kmer2sequences Description Given a set of kmers (fasta / fastq [.gz] format) and a set of sequences (fasta / fastq [.gz] format), this tool will extra

Pierre Peterlongo 22 Sep 16, 2023
A crate that allows you to mostly-safely cast one type into another type.

A crate that allows you to mostly-safely cast one type into another type. This is mostly useful for generic functions, e.g. pub fn foo<S>(s: S) {

Bincode 3 Sep 23, 2023
A lightweight and ergonomic rust crate to handle system-wide hotkeys on windows

Windows Hotkeys An opinionated, lightweight crate to handle system-wide hotkeys on windows The windows-hotkeys crate abstracts and handles all interac

null 5 Dec 15, 2022
trigger io::Error's in test, and annotate their source

fault-injection docs Similar to the try! macro or ? operator, but externally controllable to inject faults during testing. Unlike the try! macro or ?

Komora 18 Dec 16, 2022
Real-time CLI level meter built in Rust.

Meter This is a very simple command line utility written in Rust for measuring the gain of a microphone. It displays the values in dBFS. This is usefu

Chris Burgess 16 Sep 8, 2022
A library that creates a terminal-like window with feature-packed drawing of text and easy input handling. MIRROR.

BearLibTerminal provides a pseudoterminal window with a grid of character cells and a simple yet powerful API for flexible textual output and uncompli

Tommy Ettinger 43 Oct 31, 2022
Schemars is a high-performance Python serialization library, leveraging Rust and PyO3 for efficient handling of complex objects

Schemars Introduction Schemars is a Python package, written in Rust and leveraging PyO3, designed for efficient and flexible serialization of Python c

Michael Gendy 7 Nov 21, 2023
A panic hook for wasm32-unknown-unknown that logs panics with console.error

console_error_panic_hook This crate lets you debug panics on wasm32-unknown-unknown by providing a panic hook that forwards panic messages to console.

Rust and WebAssembly 241 Jan 3, 2023
Fuzzy Index for Python, written in Rust. Works like error-tolerant dict, keyed by a human input.

FuzzDex FuzzDex is a fast Python library, written in Rust. It implements an in-memory fuzzy index that works like an error-tolerant dictionary keyed b

Tomasz bla Fortuna 8 Dec 15, 2022
Pure Rust multi-line text handling

COSMIC Text Pure Rust multi-line text handling. COSMIC Text provides advanced text shaping, layout, and rendering wrapped up into a simple abstraction

Pop!_OS 1k Apr 26, 2023
Building blocks for handling potentially unsafe statics.

Grounded Building blocks for handling potentially unsafe statics. This crate aims to provide useful and sound components that serve as building blocks

James Munns 3 Nov 28, 2023