scrutiny
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.
Why?
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.
Standards used
| 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.
Getting Started
Add to your Cargo.toml:
[]
= { = "scrutiny" }
# For axum integration:
= { = "scrutiny-axum" }
Basic Usage
use Validate;
use Validate as _;
let user = CreateUser ;
assert!;
Custom Error Messages
Every rule has a sensible default message with field name interpolation. Override any rule's message inline:
Default messages use :attribute (friendly field name), :min, :max, etc. The attributes() macro maps field names to display names.
Type-Aware Rules
min, max, between, and size automatically detect the field type and do the right thing:
Tuple Structs
Newtypes get validation for free — encode your invariants in the type system:
] String);
] i32);
Use them in other structs with #[validate(nested)]:
Enums
Validate fields per variant. Unit variants always pass.
Restricting allowed variants — use in_list/not_in with strum's AsRefStr:
This works because in_list/not_in operate on any type implementing AsRef<str>.
Tuple variants work too:
Conditional Validation
Nested & Array Validation
Use nested to recursively validate nested structs and Vec elements. Errors use dot-notation paths.
// Errors: "address.city", "members.0.email", "members.2.name"
Typed Fields
Use actual types instead of validating strings — deserialization errors become field-level validation errors automatically:
If someone sends {"name": null, "id": "not-a-uuid", "created": "bad"}:
Axum users: Valid<T> handles this out of the box.
Everyone else: use scrutiny::deserialize::from_json to get the same unified errors:
use from_json;
match
Axum Integration
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 WithRejection;
async : ,
)
// After (scrutiny-axum):
use Valid;
async
Validates before your handler runs:
use Valid;
async
Custom error responses via trait:
use ;
;
async
Also available: ValidForm<T> and ValidQuery<T> for form-encoded and query parameter validation.
Available Rules (50+)
Presence & Meta
| 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 |
Type & Format
| 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) |
String
| 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 |
Size & Length
| 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 |
Comparison
| 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 |
Conditional
| 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 |
Date (ISO 8601 strict)
| 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 |
Structural
| Rule | Attribute | Description |
|---|---|---|
| nested | nested |
Recursively validate nested struct/Vec (alias: dive) |
| custom | custom = fn_name |
Custom validation function |
Architecture
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.
License
MIT