Last active
January 29, 2022 19:26
-
-
Save frasertweedale/11e405f6d76bd0cc8409d0e41836bc9f to your computer and use it in GitHub Desktop.
classy errors in rust
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* Composable errors via traits | |
A major challenge (among several) in error handling is how to deal with | |
disjoint error types produced by the libraries you use (as well as by | |
your own program) in a consistent and ergonomic way. Rust's `?` operator | |
improves the ergonomics of error handling, but it can only be used with | |
a single error type throughout a single function. Libraries define their | |
own error types and they don't know about each other, nor about the calling | |
program. So if we want to deal with errors from different libraries as | |
a single type (an sum or enum of the different underlying error types), we | |
have to "wrap" every call to each library routine to "lift" its native | |
error type into our custom, composite error type. | |
Or do we? Rust has parametric polymorphism (generics) and traits. | |
If each library expresses its error constructors via a trait, then | |
the error type can be decided by the *user* of the library. The | |
only constraint is that the error type implements that library's | |
error trait, so that the library can construct the error values. | |
If all libraries embrace this approach, then their errors are composable | |
with errors of all other libraries. The programmer of the application | |
only needs to define the composite error type, and implement the error | |
trait of each library. Thanks to trait default implementations, this | |
means implementing one trivial function for each trait. | |
If not all libraries embrace this approach, for libraries that do not | |
express their errors abstractly you will still need to do the "lifting" | |
of those error values into the composite error type yourself. But you | |
were going to have to do that anyway, so that is not really a drawback. | |
To refactor a library to use this approach is straightforward, assuming | |
the library already expresses its errors as a sum/enum: | |
- define the trait | |
- one function that takes a value of the library's error enum | |
(I name it "this" but the name doesn't matter) | |
- one function for each constructor of the enum, with a | |
default implementation that makes use of "this" | |
- implement the trait for the library's error enum | |
- refactor functions that return `Result<X, LibraryError>` | |
- replace `LibraryError` with generic error type having the new trait | |
as a constraint | |
- replace error enum constructors with calls to the corresponding | |
trait method | |
That's it. This change is backwards compatible, with one caveat: the | |
error type may now be ambiguous ("cannot infer type" compiler error) | |
for some applications and hence require fixing (monomorphisation) via | |
a type annotation, helper function or a `match` on the error value. | |
This only arises if the application code never referred to the concrete | |
error type, e.g. it used the value via the `Debug` or `Display` trait, | |
or didn't use the value at all. | |
In the example below, assume the MIME and Crypto code are from completely | |
different, independent libraries important by the main application code. | |
(I don't know anything about Rust modules/packages, and in any case its | |
probably easier to convey this technique if you can see it all in a single | |
file). | |
*/ | |
/* MIME LIBRARY CODE */ | |
#[derive(Debug)] | |
pub enum MimeError { | |
ParseError(String), | |
TransferEncodingUnknown(String), | |
CharsetUnknown(String), | |
} | |
pub trait AsMimeError: Sized { | |
fn this(MimeError) -> Self; | |
fn parse_error(s: String) -> Self { | |
AsMimeError::this(MimeError::ParseError(s)) | |
} | |
fn transfer_encoding_unknown(s: String) -> Self { | |
AsMimeError::this(MimeError::TransferEncodingUnknown(s)) | |
} | |
fn charset_unknown(s: String) -> Self { | |
AsMimeError::this(MimeError::CharsetUnknown(s)) | |
} | |
} | |
impl AsMimeError for MimeError { | |
fn this(e: MimeError) -> Self { | |
e | |
} | |
} | |
pub struct MimeMessage { | |
headers: String, // bogus, should be a map | |
body: String, | |
} | |
pub fn mime_decode<E: AsMimeError>(_s: String) -> Result<MimeMessage, E> { | |
Err(AsMimeError::parse_error(String::from("Failed to parse"))) | |
} | |
/* CRYPTO LIBRARY CODE */ | |
#[derive(Debug)] | |
pub enum CryptoError { | |
DecryptError(String), | |
VerifyError(String), | |
} | |
pub trait AsCryptoError: Sized { | |
fn this(CryptoError) -> Self; | |
fn decrypt_error(s: String) -> Self { | |
AsCryptoError::this(CryptoError::DecryptError(s)) | |
} | |
fn verify_error(s: String) -> Self { | |
AsCryptoError::this(CryptoError::VerifyError(s)) | |
} | |
} | |
impl AsCryptoError for CryptoError { | |
fn this(e: CryptoError) -> Self { | |
e | |
} | |
} | |
/* verify the signature; returns the verified message, or an error */ | |
pub fn verify<E: AsCryptoError>(_msg: String, _sig: String) | |
-> Result<String, E> { | |
Err(AsCryptoError::verify_error(String::from("Failed to verify"))) | |
} | |
/* APPLICATION CODE */ | |
/* Here we have a sum type that captures all the possible | |
* errors produced either within our application code, | |
* or by libraries we are using. | |
*/ | |
#[derive(Debug)] | |
pub enum AppError { | |
CryptoError(CryptoError), | |
MimeError(MimeError), | |
} | |
/* Implement the AsFooError traits for our composite error type */ | |
impl AsMimeError for AppError { | |
fn this(e: MimeError) -> Self { | |
AppError::MimeError(e) | |
} | |
} | |
impl AsCryptoError for AppError { | |
fn this(e: CryptoError) -> Self { | |
AppError::CryptoError(e) | |
} | |
} | |
fn main() { | |
println!("Hello world"); | |
match go() { | |
Ok(_r) => println!("We good"), | |
Err(e) => println!("Whups: {:?}", e), | |
} | |
} | |
/* go() uses two libraries that know nothing of each other, | |
* and nothing of our application. But because both libraries | |
* express their error constructors via a trait, we are able | |
* to define our AppError composite type, and both libraries | |
* return errors of that type. | |
*/ | |
fn go() -> Result<String, AppError> { | |
let s = String::from("stdin"); // bogus but you get the idea | |
let msg = mime_decode(s)?; | |
let opt_sig = check_signed(&msg); | |
match opt_sig { | |
None => Ok(msg.body), | |
Some(sig) => verify(msg.body, sig), | |
} | |
} | |
/* return signature if message signed */ | |
fn check_signed(_msg: &MimeMessage) -> Option<String> { | |
None | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment