Parse, Don't validate: An Effective Error Handling Strategy

Rust is known for it's incredibly powerful type system, helping you catch many errors and bugs at compile time.

However, most developers don't use the type system to it's full potential and instead rely on runtime checks to ensure their code is correct, and to be fair, this is understandable because many Rust developers come from languages that don't offer such a powerful type system.

In this blog post, we'll explore a different approach for you to handle errors in Rust, one that relies on parsing data instead of validating it. This approach is called "Parse, don't validate".

But before that, let's look at the traditional way of handling errors in Rust.

The Traditional Way: Validate

If you come from languages like JavaScript and Python, you might be used to writing code like this:

async fn create_user_handler(body: &str) -> Result<(), Error> {
    let body: Value = serde_json::from_str(body)?;
 
    let email = body.get("email").ok_or("Email is required")?;
    let password = body.get("password").ok_or("Password is required")?;
 
    if !validate_email(email) {
        return Err("Invalid email");
    }
 
    if !validate_password(password) {
        return Err("Invalid password");
    }
 
    // ...
    create_user(email, password).await?;
}
 
async fn create_user(email: &str, password: &str) -> Result<(), Error> {
    // ...
}

What is wrong with this code?

In the code above, we are validating both the email and password fields. This is a common pattern in many programming languages, but it has some downsides:

The create_user function is taking two string slices as argument without the need for them to be validated.

If we were re-use the create_user function in another part of our codebase, we would have to re-validate the email and password fields again, and if we forget to validate the fields before using the create_user function, we might insert invalid data into our database without noticing.

You might be thinking that we could just move the email and password validations to the create_user function like this:

async fn create_user(email: &str, password: &str) -> Result<(), Error> {
    if !validate_email(email) {
        return Err("Invalid email");
    }
 
    if !validate_password(password) {
        return Err("Invalid password");
    }
 
    // ...
}

While this is not a bad idea generally, but there is a better approach to do that which leverages the Rust type system to it's full potential.

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Parse, don't validate

The email and password fields can have their own types, we can create a Email and Password structs to represent them:

struct Email(String);
struct Password(String);

We can then define an associated function parse() for each of these structs to parse the input data and handle the validation logic inside of that method:

pub struct Email(String);
 
impl Email {
    pub fn parse(email: &str) -> Result<Email, String> {
        if email.len() < 5 {
            return Err("Email is too short".to_string());
        }
        if !email.contains("@") {
            return Err("Email is missing @".to_string());
        }
        Ok(Self(email.to_string()))
    }
}

This way, we have an Email struct type with a private field which is a String, and a parse method that returns a Result<Email, String>. The parse method will validate the email and return an Email struct if the email is valid, otherwise it will return an error message.

It's crucial to make the String field private and not public, the reason for this is that it prevents the Email type to be constructed without using the parse() method. This enforces you and the other developers to always use the parse() method to create an instance of the Email type.

To access the field of the Email struct, we can define another method as_str() to return a reference to the inner String field:

impl Email {
    ...
 
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

We can do the same thing for the password as well:

pub struct Password(String);
 
impl Password {
    pub fn parse(password: &str) -> Result<Password, String> {
        if password.len() < 8 {
            return Err("Password is too short".to_string());
        }
        Ok(Self(password.to_string()))
    }
 
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

We can then update the create_user() function to instead of taking two string slices, it will take an Email and Password struct:

async fn create_user(email: Email, password: Password) -> Result<(), Error> {
    // ...
}

And the create_user_handler() function will be updated to use the parse() method to parse the email and password fields:

async fn create_user_handler(body: &str) -> Result<(), Error> {
    let body: Value = serde_json::from_str(body)?;
 
    let email: Email = Email::parse(body.get("email").ok_or("Email is required")?)?;
    let password: Password = Password::parse(body.get("password").ok_or("Password is required")?)?;
 
    create_user(email, password).await?;
}

This way you have an incredibly type safe code that is easy to read and maintain. The Email and Password structs are now responsible for parsing and validating the input data, and the create_user function is only responsible for creating the user.

Since the String value inside the structs are private, you can't construct new instances of the structs that enforces you to only use the parse() method which ensures that any Email type is 100% validated via the parse() method.

Conclusion

In this blog post, we explored a different approach for handling errors in Rust, one that relies on parsing data instead of validating it. This approach is called "Parse, don't validate".

Instead of just taking raw data and validating it, you can create a different type for each value that needs some sort of validation, and then define an associated function parse() and a method as_str() to parse and access the inner value of the struct.

Let's break down the steps one last time:

  • Create a new type for each value that needs validation.
  • Define an associated function parse() for each type to parse and validate the input data.
  • Define a method as_str() for each type to access the inner value of the struct.
  • Update the functions to use the new types instead of raw data.

Go back to your older code and see if you can apply this approach, find the validations that you have done in your code, it could be email, password, phone number, or validating a JSON object, and try to create a new type for each of them just the way we did for the Email and Password types.

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Check out our blog

Discover more insightful articles and stay up-to-date with the latest trends.

Subscribe to our newsletter

Get the latest updates and exclusive content delivered straight to your inbox.