A powerful, Laravel-inspired validation library for Rust. Brings Laravel's validation DX to the Rust ecosystem using derive macros and the type system — no runtime string parsing.
Correct by default. Format rules delegate to dedicated, standards-compliant parsing crates — not hand-rolled regexes. Email is validated per RFC 5321, URLs per the WHATWG URL Standard, UUIDs per RFC 4122, dates per ISO 8601, and IP addresses via Rust's stdlib. Where a standard exists, we follow it.
The existing Rust validation crates (validator, garde) are limited: few rules, no conditional validation, no bail, no per-rule custom messages, no framework-aware error responses. They also tend to use simplistic regexes for format validation rather than proper parsers, leading to false positives and negatives.
This library provides 50+ validation rules, conditional logic, nested validation, first-class axum integration, and standards-compliant format validation out of the box.
| Rule | Standard | Crate |
|---|---|---|
email |
RFC 5321 | email_address |
url |
WHATWG URL | url |
uuid |
RFC 4122 | uuid |
ulid |
ULID spec | ulid |
date / datetime |
ISO 8601 | chrono |
timezone |
IANA tz database | chrono-tz |
ip / ipv4 / ipv6 |
RFC 791 / 2460 | std::net |
mac_address |
IEEE 802 | trivial format check |
Each is behind a feature flag (all on by default). Disable default features for a minimal build and opt in to what you need.
Add to your Cargo.toml:
[dependencies]
scrutiny = { path = "scrutiny" }
# For axum integration:
scrutiny-axum = { path = "scrutiny-axum" }use scrutiny::Validate;
use scrutiny::traits::Validate as _;
#[derive(Validate)]
struct CreateUser {
#[validate(required, email, bail)]
email: Option<String>,
#[validate(required, min = 2, max = 255)]
name: Option<String>,
#[validate(required, min = 8, confirmed)]
password: Option<String>,
password_confirmation: Option<String>,
#[validate(nullable, url)]
website: Option<String>,
#[validate(required, in_list("user", "admin", "editor"))]
role: Option<String>,
}
let user = CreateUser {
email: Some("test@example.com".into()),
name: Some("Jane Doe".into()),
password: Some("secretpassword".into()),
password_confirmation: Some("secretpassword".into()),
website: None,
role: Some("admin".into()),
};
assert!(user.validate().is_ok());Every rule has a sensible default message with field name interpolation. Override any rule's message inline:
#[derive(Validate)]
#[validate(attributes(name = "full name"))]
struct Profile {
#[validate(
required(message = "We need your name!"),
min(value = 2, message = "Name must be at least :min characters."),
)]
name: Option<String>,
#[validate(
required,
email(message = "That doesn't look like a valid email."),
)]
email: Option<String>,
}Default messages use :attribute (friendly field name), :min, :max, etc. The attributes() macro maps field names to display names.
min, max, between, and size automatically detect the field type and do the right thing:
#[derive(Validate)]
struct Query {
#[validate(min = 1, max = 10000)] // numeric: compares value
per_page: f64,
#[validate(min = 2, max = 255)] // string: compares length
search: String,
#[validate(min = 1, max = 10)] // vec: compares item count
tags: Vec<String>,
#[validate(size = 4)] // vec: exactly 4 items
bounding_box: Vec<f64>,
#[validate(between(min = 0, max = 100))] // numeric: value in range
score: i32,
}Newtypes get validation for free — encode your invariants in the type system:
#[derive(Validate)]
struct Email(#[validate(email)] String);
#[derive(Validate)]
struct Score(#[validate(min = 0, max = 100)] i32);Use them in other structs with #[validate(nested)]:
#[derive(Validate)]
struct UserProfile {
#[validate(required)]
name: Option<String>,
#[validate(nested)]
email: Email,
}Validate fields per variant. Unit variants always pass.
#[derive(Validate)]
enum ContactMethod {
Email {
#[validate(required, email)]
address: Option<String>,
},
Phone {
#[validate(required, min = 5)]
number: Option<String>,
},
None,
}Restricting allowed variants — use in_list/not_in with strum's AsRefStr:
#[derive(Deserialize, strum::AsRefStr)]
enum UserStatus { Active, Inactive, Banned, Suspended }
#[derive(Validate, Deserialize)]
struct CreateUser {
#[validate(in_list("Active", "Inactive"))] // rejects Banned, Suspended
status: UserStatus,
}
#[derive(Validate, Deserialize)]
struct AdminUpdate {
#[validate(not_in("Banned"))] // only rejects Banned
status: UserStatus,
}This works because in_list/not_in operate on any type implementing AsRef<str>.
Tuple variants work too:
#[derive(Validate)]
enum Wrapper {
Text(#[validate(required, min = 1)] Option<String>),
Number(#[validate(min = 0, max = 999)] i32),
Empty,
}#[derive(Validate)]
struct Registration {
#[validate(required, in_list("user", "admin"))]
role: Option<String>,
// Only required when role is "admin"
#[validate(required_if(field = "role", value = "admin", message = "Admins need a code."))]
admin_code: Option<String>,
// Prohibited for basic users
#[validate(prohibited_if(field = "role", value = "user"))]
admin_feature: Option<String>,
}Use nested to recursively validate nested structs and Vec elements. Errors use dot-notation paths.
#[derive(Validate)]
struct Address {
#[validate(required, max = 255)]
line1: Option<String>,
#[validate(required)]
city: Option<String>,
#[validate(required, regex(pattern = r"^\d{5}(-\d{4})?$", message = "Invalid ZIP."))]
zip: Option<String>,
}
#[derive(Validate)]
struct Team {
#[validate(required)]
name: Option<String>,
#[validate(nested)]
members: Vec<Member>,
#[validate(nested)]
address: Address,
}
// Errors: "address.city", "members.0.email", "members.2.name"Use actual types instead of validating strings — deserialization errors become field-level validation errors automatically:
#[derive(Validate, Deserialize)]
struct CreateUser {
#[validate(required, min = 2)]
name: Option<String>,
id: uuid::Uuid, // no #[validate(uuid)] needed
created: chrono::NaiveDate, // no #[validate(date)] needed
}If someone sends {"name": null, "id": "not-a-uuid", "created": "bad"}:
{
"errors": {
"name": ["The name field is required."],
"id": ["invalid type: string \"not-a-uuid\", expected UUID"],
"created": ["premature end of input"]
}
}Axum users: Valid<T> handles this out of the box.
Everyone else: use scrutiny::deserialize::from_json to get the same unified errors:
use scrutiny::deserialize::from_json;
match from_json::<CreateUser>(body_bytes) {
Ok(user) => { /* deserialized AND validated */ }
Err(errors) => { /* same ValidationErrors for both deser and validation */ }
}Drop-in replacement for axum::Json<T> — and for axum_extra::extract::WithRejection. You don't need axum-extra for error customization; our extractors handle deserialization, validation, and error responses in one step.
// Before (axum + axum-extra):
use axum_extra::extract::WithRejection;
async fn handler(
WithRejection(Json(body), _): WithRejection<Json<CreateUser>, AppError>,
) -> Result<impl IntoResponse> { ... }
// After (scrutiny-axum):
use scrutiny_axum::Valid;
async fn handler(Valid(body): Valid<CreateUser>) -> impl IntoResponse { ... }Validates before your handler runs:
use scrutiny_axum::Valid;
async fn create_user(Valid(user): Valid<CreateUser>) -> impl IntoResponse {
// `user` is already validated.
// Invalid requests get a 422 JSON response automatically.
}Custom error responses via trait:
use scrutiny_axum::{ValidWith, ValidationErrorResponse};
struct MyApiError;
impl ValidationErrorResponse for MyApiError {
fn from_validation_errors(errors: ValidationErrors) -> Response {
let body = json!({
"success": false,
"code": "VALIDATION_FAILED",
"details": errors.messages(),
});
(StatusCode::BAD_REQUEST, Json(body)).into_response()
}
fn from_deserialization_error(error: String) -> Response {
// ...
}
}
async fn handler(result: ValidWith<CreateUser, MyApiError>) -> impl IntoResponse {
let user = result.into_inner();
// ...
}Also available: ValidForm<T> and ValidQuery<T> for form-encoded and query parameter validation.
| Rule | Attribute | Description |
|---|---|---|
| required | required |
Field must be present and non-empty |
| filled | filled |
If present, must not be empty |
| nullable | nullable |
Skip rules if None |
| sometimes | sometimes |
Skip rules if field absent |
| bail | bail |
Stop on first error for this field |
| prohibited | prohibited |
Field must NOT be present |
| prohibited_if | prohibited_if(field, value) |
Prohibited when condition met |
| prohibited_unless | prohibited_unless(field, value) |
Prohibited unless condition met |
| Rule | Attribute | Description |
|---|---|---|
| string | string |
Must be a string (compile-time assertion) |
| integer | integer |
Must be a valid integer |
| numeric | numeric |
Must be a valid number |
| boolean | boolean |
Must be true/false/1/0 |
email |
Valid email (HTML5 spec) | |
| url | url |
Valid URL |
| uuid | uuid |
Valid UUID (8-4-4-4-12 hex) |
| ulid | ulid |
Valid ULID (26 char Crockford base32) |
| ip | ip |
Valid IP address |
| ipv4 | ipv4 |
Valid IPv4 address |
| ipv6 | ipv6 |
Valid IPv6 address |
| mac_address | mac_address |
Valid MAC address |
| json | json |
Valid JSON string |
| ascii | ascii |
Only ASCII characters |
| hex_color | hex_color |
Valid hex color (#RGB, #RRGGBB, #RRGGBBAA) |
| timezone | timezone |
Valid timezone (IANA format) |
| Rule | Attribute | Description |
|---|---|---|
| alpha | alpha |
Only alphabetic characters |
| alpha_num | alpha_num |
Only alphanumeric |
| alpha_dash | alpha_dash |
Alphanumeric + dashes + underscores |
| uppercase | uppercase |
Must be entirely uppercase |
| lowercase | lowercase |
Must be entirely lowercase |
| starts_with | starts_with = "X" |
Must start with prefix |
| ends_with | ends_with = "X" |
Must end with suffix |
| doesnt_start_with | doesnt_start_with = "X" |
Must NOT start with prefix |
| doesnt_end_with | doesnt_end_with = "X" |
Must NOT end with suffix |
| contains | contains = "X" |
Must contain substring |
| doesnt_contain | doesnt_contain = "X" |
Must NOT contain substring |
| regex | regex = "pattern" |
Must match regex |
| not_regex | not_regex = "pattern" |
Must NOT match regex |
| Rule | Attribute | Description |
|---|---|---|
| min | min = N |
Type-aware: numeric value, string length, or Vec item count |
| max | max = N |
Type-aware: numeric value, string length, or Vec item count |
| between | between(min, max) |
Type-aware: value/length/count between min and max |
| size | size = N |
Type-aware: exact value, length, or count |
| digits | digits = N |
Exact digit count |
| digits_between | digits_between(min, max) |
Digit count between min and max |
| decimal | decimal = N or decimal(min, max) |
Exact or range of decimal places |
| multiple_of | multiple_of = "N" |
Must be a multiple of N |
| Rule | Attribute | Description |
|---|---|---|
| same | same = "field" |
Must equal another field |
| different | different = "field" |
Must differ from another field |
| confirmed | confirmed |
Must match {field}_confirmation |
| gt | gt = "field" |
Greater than another field |
| gte | gte = "field" |
Greater than or equal |
| lt | lt = "field" |
Less than another field |
| lte | lte = "field" |
Less than or equal |
| in_list | in_list("a", "b", "c") |
Must be one of the values |
| not_in | not_in("a", "b") |
Must NOT be one of the values |
| in_array | in_array = "field" |
Must exist in another field's array |
| distinct | distinct |
Array items must be unique |
| Rule | Attribute | Description |
|---|---|---|
| required_if | required_if(field, value) |
Required when field equals value |
| required_unless | required_unless(field, value) |
Required unless field equals value |
| required_with | required_with = "field" |
Required when field is present |
| required_without | required_without = "field" |
Required when field is absent |
| required_with_all | required_with_all("a", "b") |
Required when ALL fields present |
| required_without_all | required_without_all("a", "b") |
Required when ALL fields absent |
| accepted | accepted |
Must be yes/on/1/true |
| accepted_if | accepted_if(field, value) |
Must be accepted when condition met |
| declined | declined |
Must be no/off/0/false |
| declined_if | declined_if(field, value) |
Must be declined when condition met |
| Rule | Attribute | Description |
|---|---|---|
| date | date |
Valid ISO 8601 date (YYYY-MM-DD) |
| datetime | datetime |
Valid ISO 8601 datetime |
| date_equals | date_equals = "YYYY-MM-DD" |
Must equal the date |
| before | before = "YYYY-MM-DD" |
Must be before the date |
| after | after = "YYYY-MM-DD" |
Must be after the date |
| before_or_equal | before_or_equal = "YYYY-MM-DD" |
Before or equal |
| after_or_equal | after_or_equal = "YYYY-MM-DD" |
After or equal |
| Rule | Attribute | Description |
|---|---|---|
| nested | nested |
Recursively validate nested struct/Vec (alias: dive) |
| custom | custom = fn_name |
Custom validation function |
scrutiny/ Core: traits, errors, rule functions
scrutiny-derive/ Proc macro: #[derive(Validate)]
scrutiny-axum/ Axum extractors + error response customization
The core is framework-agnostic. scrutiny-axum adds axum extractors behind a separate crate. The error system uses ValidationErrors with dot-notation field paths and is serde-serializable.
MIT