Combine Results for Improved Rust Validation Logic
source link: https://www.tuicool.com/articles/hit/7jmaMbY
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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.
A Simple Validation Function
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)?, } ) }
Returning All Errors
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).
Introducing multi_try
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.
fn validate_email(email: Email) -> Result<ValidatedEmail, Vec<EmailValidationErr>> { let (to, from, subject, body) = multi_try::and( validate_address(email.to) .map_err(|_| EmailValidationErr::InvalidRecipientEmailAddress), validate_address(email.from) .map_err(|_| EmailValidationErr::InvalidSenderEmailAddress) ) .and( validate_subject(email.subject) ) .and( validate_body(email.body) ) .into_result()?; 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.
Nightly Optional
If you are using Nightly Rust, you can use the nightly
feature of multi_try
for a slight ergonomics benefit. With the nightly
feature enabled, it is possible to omit the .into_result()
before the ?
operator in the example above.
Feedback Wanted
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.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK