Combinators
Union
A union tries its variants in order and returns the first success.
use vld::prelude::*;
let id = vld::union(vld::u64(), vld::string().uuid());
match id.parse("42").unwrap() {
Either::Left(n) => assert_eq!(n, 42),
Either::Right(_) => unreachable!(),
}
// Same thing as a method:
let id = vld::u64().or(vld::string().uuid());
| API | Output |
|---|---|
vld::union(a, b) / a.or(b) | Either<A, B> (is_left(), left(), right(), into_inner() when both sides have the same type) |
vld::union3(a, b, c) | Either3<A, B, C> |
vld::union!(a, b, c, d, …) | any number of schemas; 4+ are nested Eithers, first match wins |
Either / Either3 serialize as the bare value (serde untagged): a union inside an object comes back as "x" or 5, not {"Left": "x"}.
When every variant fails, the error starts with one invalid_union issue (with the received value), followed by each variant's issues with messages prefixed [variant N]. Nested unions from union! are flattened, so N counts the leaf schemas.
Discriminated union
vld::discriminated_union(field) reads one field and picks the variant by its value, instead of trying each schema. The output is the validated object (serde_json::Value). For a typed result from a serde-tagged enum, use #[derive(Validate)] on the enum.
let shape = vld::discriminated_union("type")
.variant_str("circle", vld::object()
.field("type", vld::literal("circle"))
.field("radius", vld::number().positive()))
.variant_str("rect", vld::object()
.field("type", vld::literal("rect"))
.field("w", vld::number().positive())
.field("h", vld::number().positive()));
assert!(shape.parse(r#"{"type": "circle", "radius": 2}"#).is_ok());
let err = shape.parse(r#"{"type": "triangle"}"#).unwrap_err();
assert_eq!(err.issues[0].code.key(), "invalid_discriminator");
variant(value, schema) takes any JSON value as the discriminator (1, true); variant_str is the string shortcut. Variants accept any DynSchema (every VldSchema whose output is Serialize).
Intersection
vld::intersection(a, b) or a.and(b) runs both schemas on the same input. The output of the first is returned; if either fails, all issues are merged.
let corporate = vld::string().email().and(vld::string().ends_with("@example.com"));
assert!(corporate.parse(r#""ada@example.com""#).is_ok());
Refine and super_refine
refine(check, message) adds a predicate on the parsed output. A failure reports IssueCode::Custom { code: "custom" } with your message. For a check reused across schemas, with its own code and translatable message, write a rule instead.
let even = vld::number().int().refine(|n| n % 2 == 0, "Must be even");
assert!(even.parse("3").is_err());
super_refine(|value, errors| ...) can push several issues with your own codes:
let password = vld::string().super_refine(|s, errors| {
if !s.chars().any(|c| c.is_ascii_digit()) {
errors.push(IssueCode::Custom { code: "no_digit".into() }, "Add a digit");
}
if !s.chars().any(|c| c.is_ascii_uppercase()) {
errors.push(IssueCode::Custom { code: "no_upper".into() }, "Add an uppercase letter");
}
});
assert_eq!(password.parse(r#""secret""#).unwrap_err().issues.len(), 2);
For issues with paths, use the builder: errors.issue(code).message("...").path_field("confirm").finish().
Transform
transform(f) maps the output after a successful parse; the output type changes to whatever f returns.
let words = vld::string().trim().transform(|s| s.split_whitespace().count());
assert_eq!(words.parse(r#"" a b c ""#).unwrap(), 3);
Pipe
a.pipe(b) serializes the output of a back to JSON and parses it with b. The second schema sees JSON, so a string output needs a coercing schema:
let qty = vld::string().trim().pipe(vld::number().coerce().min(0.0));
assert_eq!(qty.parse(r#"" 5 ""#).unwrap(), 5.0);
Preprocess
vld::preprocess(f, schema) rewrites the raw JSON value before validation:
let tags = vld::preprocess(
|v| match v.as_str() {
Some(s) => serde_json::json!(s.split(',').map(str::trim).collect::<Vec<_>>()),
None => v.clone(),
},
vld::array(vld::string().min(1)),
);
assert_eq!(tags.parse(r#""a, b""#).unwrap(), vec!["a", "b"]);
Custom
vld::custom(|value| -> Result<T, String>) builds a schema from a function. Errors become a custom issue with your message.
let even = vld::custom(|v: &serde_json::Value| {
let n = v.as_i64().ok_or("Expected integer")?;
if n % 2 == 0 { Ok(n) } else { Err("Must be even".into()) }
});
assert!(even.parse("4").is_ok());
Lazy (recursive schemas)
vld::lazy(factory) calls the factory on first use and caches the result, so a schema can refer to itself:
fn tree() -> ZObject {
vld::object()
.field("value", vld::number().int())
.field("children", vld::array(vld::lazy(tree)).with_default(vec![]))
}
assert!(tree().parse(r#"{"value": 1, "children": [{"value": 2}]}"#).is_ok());
A recursive schema unfolds only as deep as the input goes.
describe and message
describe("text")attaches a description. It does not affect validation and becomesdescriptionin JSON Schema.message("text")replaces the message of every issue the inner schema produces (codes, paths and received values are kept), like Zod's.message().
let name = vld::string().min(3).describe("Display name").message("Name is too short");
assert_eq!(name.parse(r#""ab""#).unwrap_err().issues[0].message, "Name is too short");