Unlock Your Python Backend Career: Build 30 Projects in 30 Days. Join now for just $54

Building a Validator System in Rust

by Ugochukwu Chizaram Omumusinachi

.

Updated Wed Apr 09 2025

.
Building a Validator System in Rust

Data validation is a cornerstone of robust applications. It ensures that the data your application processes conforms to expected formats and business rules, preventing errors and maintaining data integrity.

If you're familiar with Django REST Framework (DRF) in Python, you'll appreciate its powerful and declarative validator system.

In this article, we'll explore how to build a type-safe and flexible validator system in Rust, taking cues from DRF's elegant design. We'll start with a basic structure and progressively enhance it to create a more practical and feature-rich solution.

Defining the Validators Struct

Let's begin by opening a base Rust structure for our validator system:


type InputDataType = u32;

struct Validators<T> where T : Fn(InputDataType) -> bool {
    validators: Vec<T>,
    data: InputDataType
}

impl <T>Validators<T> where T : Fn(InputDataType) -> bool {
    fn new(validators: Vec<T>, data: InputDataType) -> Self {
        Validators { validators, data }
    }

    fn validate(&self) -> bool {
        self.validators.iter().all(|f| f(self.data))
    }
}

Let's break down this code piece by piece:

  • type InputDataType = u32;: This line defines a type alias InputDataType as u32. This signifies the type of data we intend to validate. In a real-world scenario, this could be a struct, enum, like a JSON string or even a trait object, or any other complex data type. For simplicity in this example, we're using an unsigned 32-bit integer.

  • struct Validators<T> where T : Fn(u32) -> bool { ... }: This is the heart of our validator system.

    • struct Validators<T>: We define a generic struct named Validators. The <T> indicates that Validators is generic over a type parameter T.

    • where T : Fn(InputDataType) -> bool: This is a crucial constraint. It dictates that the type T must be a function (or a closure) that:

      • Takes a InputDataType as input matching our data field.

      • Returns a bool value, indicating whether the validation passed (true) or failed (false).

    • validators: Vec<T>: This field holds a Vec (vector) of the validator functions of type T. This is where we store our collection of validation rules.

    • data: InputDataType: This field stores the actual data of type InputDataType that we want to validate.

  • impl <T>Validators<T> where T : Fn(InputDataType) -> bool { ... }: This is the implementation block for our Validators struct. Again, we use generics and the same trait bound as in the struct definition.

    • fn new(validators: Vec<T>, data: InputDataType) -> Self { ... }: This is a constructor function named new.

      • It takes a Vec<T> of validator functions and the InputDataType as arguments.

      • It returns Self, which is shorthand for Validators<T>, creating a new instance of the Validators struct with the provided values.

    • fn validate(&self) -> bool { ... }: This is the core validation logic.

      • &self: It takes an immutable reference to the Validators instance, allowing us to access its fields without taking ownership.

      • -> bool: It returns a bool value indicating the overall validation result.

      • self.validators.iter().all(|f| f(self.data)): This is the validation logic itself:

        • self.validators.iter(): We iterate over the validators vector using .iter(), which provides immutable references to each validator function.

        • .all(|f| !f(self.data)): This is a higher-order function call. .all() is a method on iterators that checks if all element in the iterator satisfies a given predicate (a boolean-returning function).

          • |f| f(self.data): This is a closure (an anonymous function). For each validator function f in the validators vector, it calls f(self.data). This executes the validator function with the input data.

          • .all() will return true if all the validator functions return true. If all validator functions return false, .all() will return false.

In essence, the current validate function checks if all of the provided validators pass for the given data. If all validators are satisfied, the overall validation is considered successful.

Enhancing the Validator System

While the core structure is functional, it has limitations and can be significantly improved to be more practical and aligned with the flexibility of DRF validators. Let's identify areas for enhancement and implement them:

  1. Adding Validators Dynamically:

    Currently, validators are provided only at the time of creating a Validators instance through the new function. It would be more flexible to add validators incrementally after the Validators object is created.

  2. Returning Descriptive Validation Errors:

    The current validate function simply returns a bool. In real-world applications, it's crucial to know why validation failed. We need to return more informative error messages or structured error data.

Let's implement these enhancements step by step.

  1. Adding Validators Dynamically

We can add a method add_validator to the Validators struct:


impl <T>Validators<T> where T : Fn(u32) -> bool {
    // ... (new function and validate from before) ...

    fn add_validator(&mut self, validator: T) {
        self.validators.push(validator);
    }
}

This add_validator function takes a mutable reference to self (&mut self) and a new validator function of type T. It simply pushes the new validator to the validators vector using .push().

Now you can create a Validators instance and add validators to it later:


let mut validator_system: Validators<Box<dyn Fn(u32) -> bool>> = Validators::new(vec![], 15); // Start with no validators

validator_system.add_validator(Box::new(|data| data > 10));
validator_system.add_validator(Box::new(|data| data % 2 != 0)); // Odd number validator

let is_valid = validator_system.validate();
println!("Is valid (any): {}", is_valid); // Output: Is valid (any): true

[!NOTE] We had to change the type of T in Validators to Box<dyn Fn(u32) -> bool>This is because trait objects (like Fn(u32) -> bool) are not sized, and to store them in a Vec, we need to put them behind a pointer, in this case, a Box. Box<dyn Trait> is a common way to work with trait objects in Rust.

Returning Descriptive Validation Errors

Returning just a bool is limiting. Let's upgrade our system to return a Result that can carry error messages when validation fails.

First, let's define a custom error type (for simplicity, we'll use String for error messages, but in a real application, you'd likely create a more structured error enum or struct) probably using the thiserror crate:


type ValidationResult = Result<bool, String>;

Now, let's modify our validate functions to return ValidationResult:


impl <T>Validators<T> where T : Fn(u32) -> bool {
    // ... (new function and add_validator from before) ...

    fn validate(&self) -> ValidationResult {
        let mut errors = Vec::new();
        for validator in &self.validators {
            if !validator(self.data) {
                // For a real system, you might want to collect error messages
                errors.push("Validation failed for a rule".to_string());
            }
        }
        if errors.is_empty() {
            Ok(true) // All validators passed
        } else {
            Err(format!("Validation failed for all rules: {:?}", errors)) // Some validators failed
        }
    }
}

In validate:

  • We iterate through all validators.

  • If a validator returns false, we add a generic error message to an errors vector.

  • If errors remains empty after checking all validators (meaning all passed), we return Ok(true).

  • Otherwise, we return Err containing a formatted error message (currently just listing generic messages, but you could enhance this to collect more specific errors).

Now, when you call validate, you get a Result that you can handle:


let mut validator_system: Validators<Box<dyn Fn(u32) -> bool>> = Validators::new(vec![], 15);

validator_system.add_validator(Box::new(|data| data > 10));
validator_system.add_validator(Box::new(|data| data % 2 != 0));
validator_system.add_validator(Box::new(|data| data < 15)); // This one will fail

let result = validator_system.validate();
match result {
    Ok(_) => println!("Validation (any) passed!"),
    Err(err) => println!("Validation (any) failed: {}", err),
} // Output: Validation (any) passed!

Complete Code Example

Here's the complete enhanced code incorporating all the improvements:


type InputDataType = u32;
type ValidationResult = Result<bool, String>;

struct Validators<T> where T : Fn(u32) -> bool {
    validators: Vec<T>,
    data: InputDataType
}

impl <T>Validators<T> where T : Fn(u32) -> bool {
    fn new(validators: Vec<T>, data: InputDataType) -> Self {
        Validators { validators, data }
    }

    fn add_validator(&mut self, validator: T) {
        self.validators.push(validator);
    }


    fn valiadte(&self) -> ValidationResult {
        let mut errors = Vec::new();
        for validator in &self.validators {
            if !validator(self.data) {
                // For a real system, you might want to collect error messages
                errors.push("Validation failed for a rule".to_string());
            }
        }
        if errors.is_empty() {
            Ok(true) // All validators passed
        } else {
            Err(format!("Validation failed for all rules: {:?}", errors)) // Some validators failed
        }
    }
}

fn main() {
    // Example Usage:

    // Using Box<dyn Fn(u32) -> bool> to store closures in Vec
    let mut validator_system: Validators<Box<dyn Fn(u32) -> bool>> = Validators::new(vec![], 15);

    validator_system.add_validator(Box::new(|data| data > 10));
    validator_system.add_validator(Box::new(|data| data % 2 != 0));
    validator_system.add_validator(Box::new(|data| data < 20));

    println!("Validating data: {}", validator_system.data);

    let result = validator_system.validate();
    match result {
        Ok(_) => println!("Validation (all) passed!"),
        Err(err) => println!("Validation (all) failed: {}", err),
    }
}

Conclusion

We've built a basic yet functional validator system in Rust inspired by the principles of Django REST Framework validators. It allows you to define a set of validation rules (as functions or closures) and apply them to your data. We enhanced the system to:

  • Dynamically add validators.

  • Return more informative validation results using Result and error messages.

In summary, you have just seen the core basics of how such a validation system might work in Rust. If you have any questions or you wish I write another article explaining how you could implement validators for your serde serialization, feel free to reach out to me on my linkedin.

Course image
Become a Rust Backend Engineeer today

All-in-one Rust course for learning backend engineering with Rust. This comprehensive course is designed for Rust developers seeking proficiency in Rust.

Start Learning Now

Whenever you're ready

There are 4 ways we can help you become a great backend engineer:

The MB Platform

Join 1000+ backend engineers learning backend engineering. Build real-world backend projects, learn from expert-vetted courses and roadmaps, track your learnings and set schedules, and solve backend engineering tasks, exercises, and challenges.

The MB Academy

The “MB Academy” is a 6-month intensive Advanced Backend Engineering BootCamp to produce great backend engineers.

Join Backend Weekly

If you like post like this, you will absolutely enjoy our exclusive weekly newsletter, Sharing exclusive backend engineering resources to help you become a great Backend Engineer.

Get Backend Jobs

Find over 2,000+ Tailored International Remote Backend Jobs or Reach 50,000+ backend engineers on the #1 Backend Engineering Job Board

Backend Tips, Every week

Backend Tips, Every week