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 aliasInputDataType
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 thatValidators
is generic over a type parameterT
. -
where T : Fn(InputDataType) -> bool
: This is a crucial constraint. It dictates that the type T must be a function (or a closure) that: -
validators: Vec<T>
: This field holds aVec
(vector) of the validator functions of typeT
. This is where we store our collection of validation rules. -
data: InputDataType
: This field stores the actual data of typeInputDataType
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 theInputDataType
as arguments. - It returns
Self
, which is shorthand forValidators<T>
, creating a new instance of theValidators
struct with the provided values. -
fn validate(&self) -> bool { ... }
: This is the core validation logic. -
&self
: It takes an immutable reference to theValidators
instance, allowing us to access its fields without taking ownership. -
-> bool
: It returns abool
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 functionf
in thevalidators
vector, it callsf(self.data)
. This executes the validator function with the input data. -
.all()
will returntrue
if all the validator functions returntrue
. If all validator functions returnfalse
,.all()
will returnfalse
.
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:
- 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.
- 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 validation 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 anerrors
vector. - If
errors
remains empty after checking all validators (meaning all passed), we returnOk(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 would write another article explaining how you could implement validators for your serde serialization, feel free to reach out to me on my LinkedIn.
Thank you for being a part of the community
Before you go:
Whenever you’re ready
There are 4 ways we can help you become a great backend engineer:
- The MB Platform: Join thousands of 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 posts 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.
Originally published at https://masteringbackend.com on March 12, 2025.
Top comments (0)