Custom rules
A rule is a named check written once and attached wherever it is needed, like a Laravel rule object. It reports its own issue code, so its message can be set per schema or translated by key.
A rule type
use vld::prelude::*;
use vld::rules::Rule;
/// `+7` and ten digits.
struct RuPhone;
impl Rule<str> for RuPhone {
fn key(&self) -> &str {
"ru_phone"
}
fn check(&self, s: &str) -> bool {
s.strip_prefix("+7")
.is_some_and(|d| d.len() == 10 && d.bytes().all(|b| b.is_ascii_digit()))
}
}
let phone = vld::string().trim().rule(RuPhone);
assert!(phone.parse(r#""+79991234567""#).is_ok());
let err = phone.parse(r#""8999""#).unwrap_err();
assert_eq!(err.issues[0].code.key(), "ru_phone"); // IssueCode::Custom { code: "ru_phone" }
ZString::rule keeps the string builder, so .min(), .email() and other rules can follow; all checks run and all failures are reported, in order. Rules are stored in schemas that are built once and shared, so a rule must be Send + Sync + 'static.
| Method | Purpose |
|---|---|
key() | issue code, with_messages key, translation key |
check(&value) | true when the value passes |
message() | optional default message (see below) |
json_schema() | optional JSON Schema keywords (feature openapi) |
On any schema
VldSchema::rule runs a Rule<Output> on the parsed value of any schema: numbers, arrays, objects, derived structs. The inner schema runs first; the rule only sees valid values.
use vld::rules::from_fn;
let even = vld::number().int().rule(from_fn("even", |n: &i64| n % 2 == 0).message("Must be even"));
assert!(even.parse("4").is_ok());
let distinct = from_fn("distinct", |v: &Vec<i64>| {
let mut s = v.clone();
s.sort_unstable();
s.windows(2).all(|w| w[0] != w[1])
});
let ids = vld::array(vld::number().int()).rule_msg(distinct, "Ids must be unique");
from_fn(key, closure) builds a rule without a type; .message(...) and .json_schema(...) add the optional parts. A rule shared between schemas can be an Arc<dyn Rule<str>>.
Rules work wherever a schema expression does: #[vld(vld::string().rule(RuPhone))] in #[derive(Validate)], schema! fields, integration crates.
Messages
The message of a failing rule is, in order of priority:
rule_msg(rule, "...");- the schema's
with_messages(|key| ...)for the rule's key (strings); - the rule's own
message(); - the generic
customtemplate (Validation error: ru_phone).
Translation (i18n) looks the rule up by its key, then by custom. Only 4 is translated: 1–3 are custom messages and are kept. So a rule meant to be translated leaves message() as None and the translation table gets an entry for its key:
use std::collections::HashMap;
let ru = vld::i18n::MapResolver::new(HashMap::from([
("ru_phone".to_string(), "Нужен номер в формате +7XXXXXXXXXX".to_string()),
]));
let err = vld::string().rule(RuPhone).parse(r#""8""#).unwrap_err();
let err = vld::i18n::translate_error(&err, &ru);
JSON Schema
json_schema() returns keywords merged into the schema the rule is attached to. A keyword the schema already has goes into allOf, so nothing is overwritten:
impl Rule<str> for RuPhone {
// ...
fn json_schema(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({ "format": "ru-phone" }))
}
}
// vld::string().rule(RuPhone) → {"type": "string", "format": "ru-phone"}
// vld::string().email().rule(RuPhone) → {"type": "string", "format": "email", "allOf": [{"format": "ru-phone"}]}
A rule without keywords is invisible in the schema; generated TypeScript (vld-ts) and fake data (vld-fake) only know what the schema says.
Patterns
With the regex feature, pattern checks a regular expression and emits it as the JSON Schema pattern:
let sku = vld::string().pattern(r"^[A-Z]{3}-\d{4}$");
// Handle an invalid pattern instead of panicking:
let p = vld::rules::Pattern::new(user_input)?;
let s = vld::string().pattern_msg(p, "Wrong SKU");
A pattern failure is invalid_string (StringValidation::Regex, with_messages key invalid_regex). Matching uses regex-lite: linear time, Rust syntax (no look-around, no backreferences), no Unicode classes such as \p{L}; when that is too limited, write a rule.