A fluent validation library for Rust. FluentVal provides a chainable, type-safe API for validating data structures with rich error reporting.
- Fluent API — chain validation rules in a readable, expressive way
- Built-in rules for strings, numbers, emails, and
Option-wrapped values - Conditional rules via
.when(...) - Custom rules via
.rule(...)and cross-property.must(...) - Rich error reporting with per-property grouping
- Two equivalent usage styles: explicit (no macro) or
rule_for!macro sugar
[dependencies]
fluentval = "1.0.0"use fluentval::{FluentValidator, rule_for, Validator};
struct User {
name: String,
email: String,
age: i32,
}
let v = FluentValidator::<User>::new();
rule_for!(v, |u| &u.name)
.not_empty(None::<String>)
.min_length(2, None::<String>)
.max_length(50, None::<String>);
rule_for!(v, |u| &u.email)
.not_empty(None::<String>)
.email(None::<String>);
rule_for!(v, |u| &u.age)
.greater_than_or_equal(18, None::<String>);
let user = User { name: "John Doe".into(), email: "john@example.com".into(), age: 25 };
let result = v.validate(&user);
if result.is_valid() {
println!("User is valid!");
} else {
for error in result.errors() {
println!("{}: {}", error.property, error.message);
}
}let v = FluentValidator::<User>::new();
v.rule_for("name", |u| &u.name).not_empty(None::<String>).min_length(2, None::<String>);
v.rule_for("email", |u| &u.email).not_empty(None::<String>).email(None::<String>);
let result = v.validate(&user);The macro takes a closure |c| &c.field and supplies the property name automatically via stringify!:
rule_for!(v, |u| &u.name).not_empty(None::<String>);
// expands to: v.rule_for("name", |u: &_| &u.name).not_empty(None::<String>);Both styles produce identical validators — pick whichever you prefer.
The must method on the rule chain receives the full object and the field value:
use fluentval::{FluentValidator, rule_for, Validator};
struct Command {
country_iso_code: String,
phone_number: String,
alt_phone_number: String,
}
fn is_valid_phone(phone: &str, country: &str) -> bool {
match country {
"US" => phone.len() == 10 && phone.chars().all(|c| c.is_ascii_digit()),
_ => phone.len() >= 8 && phone.len() <= 15,
}
}
let v = FluentValidator::<Command>::new();
rule_for!(v, |c| &c.phone_number)
.not_empty(None::<String>)
.must(|cmd, phone| is_valid_phone(phone, &cmd.country_iso_code),
"Phone number is not valid for the specified country");
rule_for!(v, |c| &c.alt_phone_number)
.not_empty(None::<String>)
.must(|cmd, alt| alt != &cmd.phone_number,
"Alternative phone must be different from primary");.when(predicate) gates the entire rule chain on a struct-level condition. When the predicate returns false, the property is treated as valid — no rules fire. Useful for "this field is only required when X" scenarios.
use fluentval::{FluentValidator, rule_for, Validator};
struct Checkout {
payment_method: String,
billing_address: String,
}
let v = FluentValidator::<Checkout>::new();
rule_for!(v, |c| &c.billing_address)
.not_empty(Some("billing address is required for card payments"))
.when(|c| c.payment_method == "CARD");
// CARD + empty billing → fails
// CASH + empty billing → passes (condition false, rule skipped)
// CARD + valid billing → passesSemantics:
- The condition is evaluated against
&T(the full struct), not the field value. - Position in the chain doesn't matter —
.when(...)gates every rule on the builder regardless of order. - Multiple
.when(...)calls AND together:
rule_for!(v, |o| &o.tracking_code)
.not_empty(None::<String>)
.when(|o| o.country == "US")
.when(|o| o.priority == "EXPRESS"); // only required for US + EXPRESS ordersRules apply transparently through Option<V> — when the field is None, value rules pass silently; when it's Some(_), the inner value is checked. Pair with .not_null(...) to require presence.
use fluentval::{FluentValidator, rule_for, Validator};
struct Person {
marital_status: String,
age: Option<usize>,
}
let v = FluentValidator::<Person>::new();
rule_for!(v, |p| &p.age)
.not_null(Some("age is required for married persons")) // None → error
.greater_than_or_equal(18, Some("must be at least 18")) // Some(<18) → error; None → skipped
.when(|p| p.marital_status == "Married");
// Married + None → "age is required..."
// Married + Some(15) → "must be at least 18"
// Married + Some(25) → passes
// Single + anything → passes (when-condition false)This works for all string and numeric rules on Option<V>:
- String:
not_empty,min_length,max_length,length,email - Numeric:
greater_than,greater_than_or_equal,less_than,less_than_or_equal,inclusive_between
Every rule accepts an optional custom message:
rule_for!(v, |u| &u.name)
.not_empty(Some("Name is required"))
.min_length(2, Some("Name must be at least 2 characters"));
rule_for!(v, |u| &u.age)
.greater_than_or_equal(18, Some("You must be at least 18"));Pass None::<String> to use the default message.
not_empty(msg)— non-empty after trimmingmin_length(n, msg)/max_length(n, msg)/length(min, max, min_msg, max_msg)email(msg)
greater_than(n, msg)/greater_than_or_equal(n, msg)less_than(n, msg)/less_than_or_equal(n, msg)inclusive_between(min, max, msg)
Numeric is implemented for i8…i64, isize, u8…u64, usize, f32, f64.
not_null(msg)— value isSome(_)
when(|t| -> bool)— gate the rule chain on a struct-level predicate
rule(|v| -> Option<String>)— custom value-level rulemust(|t, v| -> bool, msg)— predicate with access to the whole struct
RuleBuilder<T, V> is the underlying type. You can build a single rule fn without a validator — useful for unit-testing a rule chain or composing rules manually:
use fluentval::RuleBuilder;
// Cross-property rule against an enclosing struct:
let rule = RuleBuilder::for_property("alt_phone", |r: &Request| &r.alt_phone)
.not_empty(None::<String>)
.must(|r, v| v != &r.primary_phone, "alt phone cannot match primary")
.build();
let errors = rule(&Request { primary_phone: "x".into(), alt_phone: "x".into() });
// Rule on a bare value (identity accessor — T = V = String):
let rule = RuleBuilder::for_property("name", |s: &String| s)
.not_empty(None::<String>)
.min_length(2, None::<String>)
.build();
let errors = rule(&"".to_string());.build() consumes the builder and returns impl Fn(&T) -> Vec<ValidationError>. When the builder comes from FluentValidator::rule_for(...) instead, you don't call .build() — it auto-registers when dropped at the end of the statement.
Rules accumulate across separate rule_for! calls on the same field:
let v = FluentValidator::<User>::new();
rule_for!(v, |u| &u.name).not_empty(None::<String>).min_length(2, None::<String>);
rule_for!(v, |u| &u.name).max_length(50, None::<String>); // merged with the firstIf you want a property label that differs from the Rust field name (e.g., taxId for the field tax_id), use the explicit form:
v.rule_for("taxId", |u| &u.tax_id).not_empty(None::<String>);struct Address { city: String }
struct Person { address: Address }
let v = FluentValidator::<Person>::new();
rule_for!(v, |p| &p.address.city).not_empty(None::<String>);
// Error property will be "address.city"let result = v.validate(&user);
if result.is_valid() { /* ... */ }
for error in result.errors() {
println!("{}: {}", error.property, error.message);
}
let by_prop = result.errors_by_property();
if let Some(msg) = result.first_error_for("email") {
println!("Email error: {}", msg);
}v1.0 is a clean break. The previous ValidatorBuilder / standalone RuleBuilder<V> API has been removed and replaced with a single unified surface.
| v0.x | v1.0 |
|---|---|
ValidatorBuilder<T> |
Removed. Use FluentValidator<T>. |
validate(&user, &validator) free fn |
Removed. Call validator.validate(&user) directly. |
RuleBuilder<V> (single generic) |
RuleBuilder<T, V> (struct type + field type). |
RuleBuilder::<V>::for_property("name") |
RuleBuilder::for_property("name", |v: &V| v) for value-only; for_property("name", |t: &T| &t.field) for struct-aware. |
.must(|v| -> bool, msg) (1-arg closure) |
.must(|t, v| -> bool, msg) (2-arg; use |_, v| … for value-only). |
let validator = v.build(); then validator.validate(&u) |
Just v.validate(&u) — no separate build step. |
PropertyRuleBuilder<T, V> (internal) |
Removed. Merged into RuleBuilder<T, V>. |
ValidationError had a property: String field — unchanged |
(No change.) |
.when(predicate)— conditional rule chain (predicate receives&T).- Option-transparent rules — string and numeric rules apply through
Option<V>automatically;Nonevalues pass,Some(_)is checked. NotOptionmarker trait (sealed) — used internally to disambiguate bare-value impls fromOption<V>impls. You generally won't reference it directly.Numericnow coversisizeandusizein addition to the other integer/float primitives.RuleBuilderfromFluentValidator::rule_for(...)auto-registers on drop — no explicit registration call needed.
// v0.x
let validator = ValidatorBuilder::<User>::new()
.rule_for(
"name",
|u| &u.name,
RuleBuilder::for_property("name")
.not_empty(None::<String>)
.min_length(2, None::<String>),
)
.must("phone", |u| &u.phone,
|u, phone| phone != &u.alt_phone, "phone must differ from alt_phone")
.build();
let result = validate(&user, &validator);
// v1.0
let v = FluentValidator::<User>::new();
rule_for!(v, |u| &u.name).not_empty(None::<String>).min_length(2, None::<String>);
rule_for!(v, |u| &u.phone)
.must(|u, phone| phone != &u.alt_phone, "phone must differ from alt_phone");
let result = v.validate(&user);MIT — see LICENSE.
Contributions welcome — please open a PR or issue.