Skip to content

How to Validate Structs in Rust

To validate a struct in Rust, derive ZodSchema on the struct and annotate fields with #[zod(...)] attributes that express your business rules — email format, string length, numeric range, and more. Call validate_and_parse() with JSON input to validate and deserialize in one step, getting either a typed struct or structured errors with the full path to every failing field.

Rust’s type system guarantees that a String is a string and a u32 is an unsigned integer, but it says nothing about meaning. An email field happily holds "nope", a username can be empty, and an age of 5000 passes the compiler without complaint:

#[derive(serde::Deserialize)]
struct User {
username: String, // empty string? fine.
email: String, // "not-an-email"? fine.
age: u32, // 5000? fine.
}

Deserializing with serde_json::from_str gives you a typed struct, but the data inside can still be nonsense. You end up scattering if checks throughout your handlers, and when something fails the caller gets a generic “bad request” with no indication of which field caused it.

You need struct validation — rules attached to fields, checked automatically, with clear errors.

The simplest approach is a validate method or a TryFrom impl:

use std::convert::TryFrom;
struct User {
username: String,
email: String,
age: u32,
}
impl TryFrom<RawInput> for User {
type Error = Vec<String>;
fn try_from(input: RawInput) -> Result<Self, Self::Error> {
let mut errors = Vec::new();
if input.username.len() < 3 || input.username.len() > 20 {
errors.push("username must be 3-20 characters".into());
}
if !input.email.contains('@') {
errors.push("email is invalid".into());
}
if input.age > 120 {
errors.push("age must be 120 or less".into());
}
if errors.is_empty() {
Ok(User { username: input.username, email: input.email, age: input.age })
} else {
Err(errors)
}
}
}

This works for small structs, but it doesn’t scale:

  • Every field needs hand-written checks and hand-written messages.
  • Nested structs require manual plumbing to propagate error paths.
  • Rules are spread across procedural code instead of declared next to the fields they apply to.
  • There is no standard error shape, so every struct invents its own.
Section titled “Approach 2: Derive-macro validation with zod-rs (recommended)”

zod-rs lets you declare validation rules as attributes on fields and generates the validation logic at compile time. Add it to your project:

[dependencies]
zod-rs = { version = "1.1", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Then annotate your struct:

use serde::{Deserialize, Serialize};
use zod_rs::prelude::*;
#[derive(Debug, Serialize, Deserialize, ZodSchema)]
struct User {
#[zod(min_length(3), max_length(20), regex(r"^[a-zA-Z0-9_]+$"))]
username: String,
#[zod(email)]
email: String,
#[zod(min(13.0), max(120.0), int)]
age: u32,
bio: Option<String>, // optional — no validation rules needed
#[zod(nonnegative)]
score: f64,
}

Every #[zod(...)] attribute maps to a validation check: min_length, max_length, email, url, regex, min, max, int, positive, nonnegative, and more. See the full list in the attributes reference.

The derive macro generates several methods on your struct. The most useful is validate_and_parse, which takes a serde_json::Value, validates it against the schema, and returns your typed struct:

use serde_json::json;
let data = json!({
"username": "alice_dev",
"email": "[email protected]",
"age": 28,
"score": 95.5
});
match User::validate_and_parse(&data) {
Ok(user) => println!("Valid: {:?}", user),
Err(errors) => {
for issue in &errors.issues {
println!(" - {}", issue);
}
}
}

When the input is a raw JSON string — from an HTTP body, a file, or a message queue — skip the intermediate Value:

let user = User::from_json(r#"{
"username": "alice_dev",
"email": "[email protected]",
"age": 28,
"score": 95.5
}"#)?;

from_json parses the JSON and validates it in one call. If either step fails you get structured errors, not a serde deserialization panic.

You can also extract the schema and use it independently — pass it around, store it, compose it with other schemas:

let schema = User::schema();
// Validate without deserializing
let result = schema.validate(&data);
// Reuse the same schema across multiple inputs
for payload in incoming_messages {
if schema.validate(&payload).is_ok() {
process(payload);
}
}

This is what makes zod-rs different from attribute-only validators: schemas are runtime values, not just compile-time annotations. See Schema Composition for how to combine them.

When a field’s type also derives ZodSchema, nested validation happens automatically — no extra attributes needed:

#[derive(Debug, Serialize, Deserialize, ZodSchema)]
struct Address {
#[zod(min_length(5), max_length(200))]
street: String,
#[zod(min_length(2), max_length(50))]
city: String,
#[zod(length(2))]
country_code: String,
}
#[derive(Debug, Serialize, Deserialize, ZodSchema)]
struct UserProfile {
#[zod(min_length(2), max_length(50))]
name: String,
#[zod(email)]
email: String,
address: Address, // required nested struct
billing_address: Option<Address>, // optional nested struct
#[zod(min_length(1))]
tags: Vec<String>,
}

Vectors of nested structs — like Vec<Address> — also validate every element automatically.

When validation fails inside a nested struct, the error includes the complete path to the failing field. This is critical for API responses where the caller needs to know exactly what to fix:

let data = json!({
"name": "A",
"email": "bad",
"address": {
"street": "1",
"city": "B",
"country_code": "USA"
},
"tags": []
});
match UserProfile::validate_and_parse(&data) {
Err(errors) => {
for issue in &errors.issues {
println!(" - {}", issue);
}
}
_ => {}
}

Output:

- name: Too small: expected string to have >= 2 characters
- email: Invalid email address
- address.street: Too small: expected string to have >= 5 characters
- address.city: Too small: expected string to have >= 2 characters
- address.country_code: Expected exactly 2 characters
- tags: Too small: expected array to have >= 1 items

Every error carries the dotted path (address.street, address.city), and array elements use bracket notation (items[0].name). See Error Handling for the complete error model.

For rules that go beyond built-in attributes — cross-field checks, database lookups, domain-specific logic — implement the Schema trait:

use zod_rs::prelude::*;
use zod_rs_util::{ValidationError, ValidateResult};
use serde_json::Value;
struct PasswordStrength {
min_length: usize,
}
impl Schema<String> for PasswordStrength {
fn validate(&self, value: &Value) -> ValidateResult<String> {
let s = value.as_str()
.ok_or_else(|| ValidationError::invalid_type("string", "other"))?
.to_string();
if s.len() < self.min_length {
return Err(ValidationError::custom(
format!("Password must be at least {} characters", self.min_length)
).into());
}
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let has_upper = s.chars().any(|c| c.is_uppercase());
if !has_digit || !has_upper {
return Err(ValidationError::custom(
"Password must contain at least one digit and one uppercase letter"
).into());
}
Ok(s)
}
}

You can use custom schemas alongside built-in ones via schema composition.

Manual (TryFrom)zod-rsvalidator crate
Declaration styleProcedural code#[zod(...)] attributes#[validate(...)] attributes
ValidatesWhatever you codeRaw JSON valuesAlready-deserialized structs
Parse + validate in one stepYou build itBuilt inNo — deserialize first
Nested error pathsYou build itAutomatic (a.b.c)Field-level only
Runtime schema buildingN/AYes — schemas are valuesNo
TypeScript codegenNoYes — #[derive(ZodTs)]No
Built-in i18nNoYesNo
Effort for 1 structLowLowLow
Effort for 20 nested structsVery highLowMedium

Use manual validation when you have one or two simple structs and want zero dependencies.

Use zod-rs when your data arrives as JSON, when you have nested structs, when you need runtime schema composition, or when your frontend and backend share the same validation rules. See Choosing a Rust Validation Library for a deeper comparison including the garde crate.

Use the validator crate when you only validate structs that are already deserialized and don’t need runtime schema flexibility.