Schemas

Custom rules

Reusable named checks (the Rule trait), closure rules, messages and translations by key, JSON Schema keywords, and patterns.

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.

MethodPurpose
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:

  1. rule_msg(rule, "...");
  2. the schema's with_messages(|key| ...) for the rule's key (strings);
  3. the rule's own message();
  4. the generic custom template (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.