2019-02-23
The error handling features within Rust are some of my favorite things about the language. The Result
and Option
types save developers from using implicit placeholder values (things like -1
and null
respectively) in almost all cases. Additionally, the try
/?
operators make it ergonomic to handle these error conditions, while the compiler ensures you can't use the underlying value without first confirming it is ok.
This system works great when you are in a function which returns a Result
and you want to exit at the first error you come to. However, it can be challenging if your goal is to try a few failure-prone things and return each of the errors, rather than just the first error. This is the problem multi_try attempts to solve.
Throughout this blog post we'll use the problem of validating an email to demonstrate the various approaches. Our goal will be to convert from the Email
struct to the ValidatedEmail
struct, and return EmailValidationErr
if there are any problems.
struct Email<'a> {
to: &'a str,
from: &'a str,
subject: &'a str,
body: &'a str,
}
struct ValidatedEmail<'a> {
to: &'a str,
from: &'a str,
subject: &'a str,
body: &'a str,
}
enum EmailValidationErr {
InvalidEmailAddress,
InvalidRecipientEmailAddress,
InvalidSenderEmailAddress,
InvalidSubject,
InvalidBody,
}
The function below demonstrates a typical approach to performing this type of validation which will validate the to
, from
, subject
, and body
fields in that order, and return either the ValidatedEmail
or the first EmailValidationErr
.
fn validate_email(email: Email) -> Result<ValidatedEmail, EmailValidationErr> {
Ok(
ValidatedEmail {
to: validate_address(email.to)
.map_err(|_| EmailValidationErr::InvalidRecipientEmailAddress)?,
from: validate_address(email.from)
.map_err(|_| EmailValidationErr::InvalidSenderEmailAddress)?,
subject: validate_subject(email.subject)?,
body: validate_body(email.body)?,
}
)
}
If these error messages are being returned to a user, it would be nice if we could provide a message about all of the validation errors, rather than just the first error. A potential approach for this is demonstrated below.
fn validate_email(email: Email) -> Result<ValidatedEmail, Vec<EmailValidationErr>> {
let mut errors = vec![];
let to = validate_address(email.to)
.unwrap_or_else(|_e| {
errors.push(EmailValidationErr::InvalidRecipientEmailAddress);
""
});
let from = validate_address(email.from)
.unwrap_or_else(|_e| {
errors.push(EmailValidationErr::InvalidSenderEmailAddress);
""
});
let subject = validate_subject(email.subject)
.unwrap_or_else(|e| {
errors.push(e);
""
});
let body = validate_body(email.body)
.unwrap_or_else(|e| {
errors.push(e);
""
});
if !errors.is_empty() {
return Err(errors);
}
Ok( ValidatedEmail { to, from, subject, body } )
}
Note how the error type in the return value changed from a EmailValidationErr
to a Vec<EmailValidationErr>
, indicating we are now returning all of the validation errors rather than just the first. This could provide a nice UX benefit, but we pay the price in code complexity.
Most critically we are giving up on an important guarantee that idiomatic Rust typically provides us. In order to continue past the first error, we use unwrap_or_else
to provide a placeholder value to the fields of our email struct (in this case we use the empty string), and then we push errors into the error vec. The downside to this approach is that once we initialize the fields to an empty string, the compiler no longer knows/cares that they are invalid, so it cannot enforce that we check for errors before using the values (this code would still compile if I removed the if !errors.is_empty()
block).
What we really want is to combine the compiler guarantees of the first approach, with the UX benefits of the second approach, and that is where multi_try comes in.
use multi_try::MultiTry;
fn validate_email(email: Email) -> Result<ValidatedEmail, Vec<EmailValidationErr>> {
let (to, from, subject, body) = validate_address(email.to).map_err(|_| {
EmailValidationErr::InvalidRecipientEmailAddress
}).and_try(validate_address(email.from).map_err(|_| {
EmailValidationErr::InvalidSenderEmailAddress
})).and_try(
validate_subject(email.subject)
).and_try(
validate_body(email.body)
)?;
Ok(ValidatedEmail { to, from, subject, body })
}
This approach provides the best of both worlds. We are still returning a Vec<EmailValidationErr>
to get the UX benefit of returning all of the errors rather than just the first, and the compiler ensures we check for errors before using the to
, from
, subject
and body
fields to bulid the ValidatedEmail
. Unlike our second attempt, if I removed the error handling (perhaps by removing the ?
) this code would no longer compile.
This crate is in the experimental phase, and all feedback is appreciated. Feel free to create an issue to express any suggestions, questions, or criticisms.
I've edited this blog post based on the changes to multi_try
from Github user sunjay. Sunjay submitted a very well written pull request improving the multi_try
API by implementing it as an extension trait on the standard Result
type.