Schemas

Combinators

Unions, intersections, discriminated unions, refinements, transforms, pipes, preprocessing, lazy and custom schemas.

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());
APIOutput
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 becomes description in 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");