Validate fields and types in serde with TryFrom

equalma

EqualMa

Posted on October 10, 2021

Validate fields and types in serde with TryFrom

serde might be the most popular serializing / deserializing framework in rust but it doesn't support validation out of box.

However, with the TryFrom trait and #[serde(try_from = "FromType")], we can easily validate types and fields when deserializing.

Validate scalar values

Imagine we are developing a user system, where users' email should be validated before constructed. In rust, we can define a single-element tuple struct which contains a String to represent Email.

pub struct Email(String);
Enter fullscreen mode Exit fullscreen mode

Email should only be constructed after validation so we can impl try_new as the only way to construct Email.

impl Email {
    // Here we use a String to represent error just for simplicity
    // You can define a custom enum type like EmailParseError in your application
    pub fn try_new(email: String) -> Result<Self, String> {
        if validate_email(&email) {
            Ok(Self(email))
        } else {
            Err(format!("Invalid email {}", email))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And some methods to consume or reference the inner String

impl Email {
    pub fn into_inner(self) -> String { self.0 }
    pub fn inner(&self) -> &String { &self.0 }
}
Enter fullscreen mode Exit fullscreen mode

With the above code, if we have a Email, we would know the inner string is already validated; If we have a String, we must call Email::try_new to validate it.

Now we have designed a Email struct and we want to deserialize it from a string with serde. Then we might code like:

use serde::Deserialize;

#[derive(Deserialize)]
pub struct Email(String);

let email: Email = serde_json::from_str("\"some_json_string\"").unwrap();
// Email("some_json_string".to_string())
Enter fullscreen mode Exit fullscreen mode

Now Email can be deserialized from a string but is not validated!

Thanks to try_from attribute in serde, we can tell serde to deserialize a string into String first, and pass the String to Email::try_from to get a Email.

use serde::Deserialize;
use std::convert::TryFrom;

#[derive(Deserialize)]
// Here we tell serde to call `Email::try_from` with a `String`
#[serde(try_from = "String")]
pub struct Email(String);

impl TryFrom<String> for Email {
    type Error = String;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Email::try_new(value)
    }
}
Enter fullscreen mode Exit fullscreen mode
let email: Email = serde_json::from_str("\"user@example.com\"").unwrap();
// Email("user@example.com".to_string())
Enter fullscreen mode Exit fullscreen mode

The above code for deserializing is equivalent to the following:

let string_value: String = serde_json::from_str("\"user@example.com\"").unwrap();
let email: Email::try_from(string_value).unwrap();
Enter fullscreen mode Exit fullscreen mode

Validate fields

We can easily use Email as the type of a field to validate it when deserializing the struct.

#[derive(Deserialize)]
pub struct User {
    name: String,
    email: Email,
}

let user: User = serde_json::from_str(
    r#"{"name": "Alice", "email": "user@example.com"}"#
).unwrap();
// User {
//     name: "Alice".to_string(),
//     email: Email("user@example.com".to_string()),
// }
Enter fullscreen mode Exit fullscreen mode

Validate structs

Sometimes, the struct itself should be validated before constructed. For example, we have an input struct ValueRange which has two fields min and max. min should be not larger than max.

Similar to Email we can define ValueRange like the following:

pub struct ValueRange {
    min: i32,
    max: i32,
}

impl ValueRange {
    pub fn try_new(min: i32, max: i32) -> Result<Self, String> {
        if min <= max {
            Ok(ValueRange { min, max })
        } else {
            Err("Invalid ValueRange".to_string())
        }
    }

    pub fn min(&self) -> i32 {
        self.min
    }
    pub fn max(&self) -> i32 {
        self.max
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that calling ValueRange::try_new is the only way to construct a ValueRange. But if we just derive #[derive(Deserialize)] for ValueRange, it will be deserialized without validation.

Thus, we can introduce a new type ValueRangeUnchecked which shares the same data structure with ValueRange.

#[derive(Deserialize)]
struct ValueRangeUnchecked {
    min: i32,
    max: i32,
}
Enter fullscreen mode Exit fullscreen mode

Then tell serde to deserialize data into ValueRangeUnchecked first and then convert it into ValueRange by calling ValueRange::try_from.

#[derive(Deserialize)]
#[serde(try_from = "ValueRangeUnchecked")]
pub struct ValueRange {
    min: i32,
    max: i32,
}

impl TryFrom<ValueRangeUnchecked> for ValueRange {
    type Error = String;

    fn try_from(value: ValueRangeUnchecked) -> Result<Self, Self::Error> {
        let ValueRangeUnchecked { min, max } = value;
        Self::try_new(min, max)
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that we can keep ValueRangeUnchecked only visible to this mod as it is only used privately in deserialization.

let range: ValueRange = serde_json::from_str(r#"{"min": 1, "max": 10}"#).unwrap();
Enter fullscreen mode Exit fullscreen mode

The above code for deserialization is equivalent to:

let range_unchecked: ValueRangeUnchecked = serde_json::from_str(r#"{"min": 1, "max": 10}"#).unwrap();
let range: ValueRange = ValueRange::try_from(range_unchecked).unwrap();
Enter fullscreen mode Exit fullscreen mode

Full code

For the full working code, you can checkout this repo:

GitHub logo EqualMa / serde-validation-with-try-from

Validate fields in serde with TryFrom trait

Thanks for reading! If this post helps you, you can buy me a coffee ♥.

💖 💪 🙅 🚩
equalma
EqualMa

Posted on October 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related