vld 0.3.0

Type-safe runtime validation library for Rust, inspired by Zod
Documentation

Crates.io docs.rs License Platform GitHub issues GitHub stars

vld

Type-safe runtime validation for Rust, inspired by Zod.

vld combines schema definition with type-safe parsing. Define your validation rules once and get both runtime checks and strongly-typed Rust structs.

Crates.io Docs.rs License: MIT


Features

  • Zero-cost schema definitions — the schema! macro generates plain Rust structs with built-in parse() methods. Or use schema_validated! to get lenient parsing too.
  • Error accumulation — all validation errors are collected, not just the first one.
  • Rich primitives — string, number, integer, boolean, literal, enum, any, custom.
  • Extra primitivesdecimal (feature decimal), duration, path, bytes, file (feature file).
  • Extended file validationfile-advanced enables hash checks, image metadata, EXIF, and advanced media-type checks.
  • String formats — email, URL, UUID, IPv4, IPv6, Base64, ISO date/time/datetime, hostname, CUID2, ULID, Nano ID, emoji. All validated without regex by default. Every check has a _msg variant for custom messages.
  • Composable — optional, nullable, nullish, default, catch, refine, super_refine, transform, pipe, preprocess, describe, .or(), .and().
  • Collections — arrays, tuples (up to 6), records, Map (HashMap), Set (HashSet).
  • Unionsunion(a, b), union3(a, b, c), .or(), discriminated_union("field"), intersection(a, b), .and().
  • Recursive schemaslazy() for self-referencing data structures (trees, graphs).
  • Dynamic objectsstrict(), strip(), passthrough(), pick(), omit(), extend(), merge(), partial(), required(), catchall(), keyof().
  • Custom schemasvld::custom(|v| ...) for arbitrary validation logic.
  • Multiple input sources — parse from &str, String, &[u8], Path, PathBuf, or serde_json::Value.
  • Validate existing values.validate(&value) and .is_valid(&value) work with any Serialize type. schema! structs get Struct::validate(&instance).
  • Lenient parsingparse_lenient() returns ParseResult<T> with the struct, per-field diagnostics, and .save_to_file().
  • Error formattingprettify_error, flatten_error, treeify_error utilities.
  • Custom error messages_msg variants, type_error(), and with_messages() for per-check and bulk message overrides, including translations.
  • JSON Schema / OpenAPIJsonSchema trait on all schema types; json_schema() and to_openapi_document() on schema! structs; field_schema() for rich object property schemas; to_openapi_document_multi() helper.
  • Derive macro#[derive(Validate)] with #[vld(...)] attributes (optional derive feature).
  • Benchmarks — criterion-based benchmarks included.
  • CI — GitHub Actions workflow for testing, clippy, and formatting.
  • Minimal dependencies by default — heavy integrations are behind opt-in features.

Quick Start

Add to your Cargo.toml:

[dependencies]
vld = "0.1"

Optional Features

Default build enables only std. Optional features:

Feature Description
serialize Adds #[derive(Serialize)] on error/result types, enables VldSchema::validate()/is_valid(), ParseResult::save_to_file()/to_json_string()/to_json_value()
deserialize Adds #[derive(Deserialize)] on error/result types
openapi Enables JsonSchema trait, to_json_schema(), json_schema(), to_openapi_document(), field_schema()
diff Schema diffing — compare two JSON Schemas to detect breaking vs non-breaking changes
regex Custom regex patterns via .regex() (uses regex-lite)
derive #[derive(Validate)] procedural macro
chrono ZDate / ZDateTime types with chrono parsing
decimal Enables decimal schema (vld::decimal()) backed by rust_decimal
net Enables network schema (vld::ip_network()) backed by ipnet
file Enables file schema (vld::file()) and basic file checks (size/extensions/media type)
file-advanced Advanced file checks: hash (sha2, md-5), image dimensions (image), EXIF (kamadak-exif)
string-advanced Advanced string checks: strict URL/URI, UUID versions, strict E.164, full semver (url, uuid, phonenumber, semver)

Enable features as needed:

[dependencies]
vld = { version = "0.1", features = ["serialize", "openapi"] }

Basic Usage

use vld::prelude::*;

// Define a validated struct
vld::schema! {
    #[derive(Debug)]
    pub struct User {
        pub name: String => vld::string().min(2).max(50),
        pub email: String => vld::string().email(),
        pub age: Option<i64> => vld::number().int().gte(18).optional(),
    }
}

// Parse from a JSON string
let user = User::parse(r#"{"name": "Alex", "email": "alex@example.com"}"#).unwrap();
assert_eq!(user.name, "Alex");
assert_eq!(user.age, None);

// Errors are accumulated
let err = User::parse(r#"{"name": "A", "email": "bad"}"#).unwrap_err();
assert!(err.issues.len() >= 2);

Nested Structs

use vld::prelude::*;

vld::schema! {
    #[derive(Debug)]
    pub struct Address {
        pub city: String => vld::string().min(1),
        pub zip: String  => vld::string().len(6),
    }
}

vld::schema! {
    #[derive(Debug)]
    pub struct User {
        pub name: String       => vld::string().min(2),
        pub address: Address   => vld::nested(Address::parse_value),
    }
}

let user = User::parse(r#"{
    "name": "Alex",
    "address": {"city": "New York", "zip": "100001"}
}"#).unwrap();

Primitives

String

vld::string()
    .min(3)                 // minimum length
    .max(100)               // maximum length
    .len(10)                // exact length
    .email()                // email format
    .url()                  // URL format (http/https)
    .uuid()                 // UUID format
    .ipv4()                 // IPv4 address
    .ipv6()                 // IPv6 address
    .base64()               // Base64 string
    .iso_date()             // ISO 8601 date (YYYY-MM-DD)
    .iso_time()             // ISO 8601 time (HH:MM:SS)
    .iso_datetime()         // ISO 8601 datetime
    .hostname()             // valid hostname
    .cuid2()                // CUID2 format
    .ulid()                 // ULID format (26 chars, Crockford Base32)
    .nanoid()               // Nano ID format (alphanumeric + _-)
    .emoji()                // must contain emoji
    .url_strict()           // strict http/https URL with host
    .uri()                  // URI via parser
    .uuid_v1()              // UUID version 1
    .uuid_v4()              // UUID version 4
    .uuid_v7()              // UUID version 7
    .phone_e164_strict()    // strict E.164 phone
    .semver_full()          // strict semver parser
    .slug()                 // [a-z0-9-], no edge dashes
    .color()                // #RRGGBB/#RRGGBBAA/rgb(...)/hsl(...)
    .currency_code()        // ISO-4217-like (e.g. USD)
    .country_code()         // ISO-3166 alpha-2 (e.g. US)
    .locale()               // ll or ll-RR (e.g. en-US)
    .cron()                 // cron expression (5/6 fields)
    .starts_with("prefix")  // must start with
    .ends_with("suffix")    // must end with
    .contains("sub")        // must contain
    .non_empty()            // must not be empty
    .trim()                 // trim whitespace before validation
    .to_lowercase()         // convert to lowercase
    .to_uppercase()         // convert to uppercase
    .coerce()               // coerce numbers/booleans to string

Number

vld::number()
    .min(0.0)       // minimum (inclusive)
    .max(100.0)     // maximum (inclusive)
    .gt(0.0)        // greater than (exclusive)
    .lt(100.0)      // less than (exclusive)
    .positive()     // > 0
    .negative()     // < 0
    .non_negative() // >= 0
    .finite()       // not NaN or infinity
    .multiple_of(5.0)
    .safe()         // JS safe integer range (-(2^53-1) to 2^53-1)
    .int()          // switch to integer mode (i64)
    .coerce()       // coerce strings/booleans to number

Decimal

let price = vld::decimal()
    .min("0.00")
    .max("999999.99")
    .non_negative();

Duration (std feature)

let timeout = vld::duration()
    .min_secs(1)
    .max_secs(30);
// accepts: 10, "10s", "250ms", "PT10S"

Path (std feature)

let cfg = vld::path().exists().file().absolute();
let dir = vld::path().exists().dir();

Integer

vld::number().int()
    .min(0)
    .max(100)
    .gte(18)
    .positive()
    .non_positive()

Bytes

vld::bytes()
    .min_len(1)
    .max_len(1024)
    .len(32)
    .non_empty()
    .base64()    // parse Base64 string
    .base64url() // parse Base64URL string
    .hex()       // parse hex string

Decimal

let price = vld::decimal().min("0.00").max("99999.99").non_negative();

Duration (std feature)

let timeout = vld::duration().min_secs(1).max_secs(30);
// accepts: 10, "10s", "250ms", "PT10S"

Path (std feature)

let cfg = vld::path().exists().file().absolute();
let safe_rel = vld::path().relative().within("/app/config");

Boolean

vld::boolean()
    .coerce()  // "true"/"false"/"1"/"0" -> bool

Literal

vld::literal("admin")   // exact string match
vld::literal(42i64)      // exact integer match
vld::literal(true)       // exact boolean match

Enum

vld::enumeration(&["admin", "user", "moderator"])

Any

vld::any()  // accepts any JSON value

Date / Datetime (chrono feature)

vld::datetime()
    .past()
    .future()
    .naive_allowed(false) // disallow naive datetime without timezone
    .with_timezone_only(); // alias for naive_allowed(false)

// Require explicit +03:00 timezone in RFC3339 input
vld::datetime().timezone_offset_only(3 * 3600);

// For naive input, interpret wall-clock time in +03:00 before normalizing to UTC
vld::datetime().naive_timezone_offset(3 * 3600);

File (std feature)

// In-memory mode (default): path + metadata + bytes
let f = vld::file()
    .non_empty()
    .max_size(5 * 1024 * 1024)
    .extension("png")
    .media_type("image/png")
    .parse_value(&serde_json::json!("/tmp/avatar.png"))?;

println!("{} {}", f.path().display(), f.size());
let bytes = f.bytes().unwrap();

// Path-only mode: store only path/metadata, open/read lazily later
let f = vld::file()
    .store_path_only()
    .parse_value(&serde_json::json!("/tmp/report.pdf"))?;
let data = f.read_bytes()?; // lazy read from disk
let handle = f.open()?;     // std::fs::File

// Advanced checks: checksums / image constraints / magic-type rules
let f = vld::file()
    .sha256("...expected sha256 hex...")
    .md5("...expected md5 hex...")
    .allow_magic_type("png")
    .deny_magic_type("exe")
    .min_width(128)
    .min_height(128)
    .require_exif()
    .parse_value(&serde_json::json!("/tmp/photo.png"))?;

IP Network / Socket Addr / JSON Value

let net = vld::ip_network().ipv4_only();      // "10.0.0.0/24"
let addr = vld::socket_addr().min_port(1024); // "127.0.0.1:8080"
let any = vld::json_value().object().require_key("id").max_depth(4);

Modifiers

// Optional: null/missing -> None
vld::string().optional()

// Nullable: null -> None
vld::string().nullable()

// Nullish: both optional + nullable
vld::string().nullish()

// Default: null/missing -> default value
vld::string().with_default("fallback".to_string())

// Catch: ANY error -> fallback value
vld::string().min(3).catch("default".to_string())

Collections

Array

vld::array(vld::string().non_empty())
    .min_len(1)
    .max_len(10)
    .len(5)       // exact length
    .non_empty()  // alias for min_len(1)

Tuple

// Tuples of 1-6 elements
let schema = (vld::string(), vld::number().int(), vld::boolean());
let (s, n, b) = schema.parse(r#"["hello", 42, true]"#).unwrap();

Record

vld::record(vld::number().int().positive())
    .min_keys(1)
    .max_keys(10)

Map

// Input: [["a", 1], ["b", 2]] -> HashMap
vld::map(vld::string(), vld::number().int())

Set

// Input: ["a", "b", "a"] -> HashSet {"a", "b"}
vld::set(vld::string().min(1))
    .min_size(1)
    .max_size(10)

Combinators

Union

// Union of 2 types
let schema = vld::union(vld::string(), vld::number().int());
// Returns Either<String, i64>

// Union of 3 types
let schema = vld::union3(vld::string(), vld::number(), vld::boolean());
// Returns Either3<String, f64, bool>

union! macro

For convenience, use the union! macro to combine 2–6 schemas without nesting calls manually. The macro dispatches to union() / union3() or nests them automatically for higher arities:

use vld::prelude::*;

// 2 schemas — same as vld::union(a, b)
let s2 = vld::union!(vld::string(), vld::number());

// 3 schemas — same as vld::union3(a, b, c)
let s3 = vld::union!(vld::string(), vld::number(), vld::boolean());

// 4 schemas — nested automatically
let s4 = vld::union!(
    vld::string(),
    vld::number(),
    vld::boolean(),
    vld::number().int(),
);

// 5 and 6 schemas work the same way
let s5 = vld::union!(
    vld::string(),
    vld::number(),
    vld::boolean(),
    vld::number().int(),
    vld::literal("hello"),
);

You can also use the method chaining equivalent .or() for two schemas:

let schema = vld::string().or(vld::number().int());

Discriminated Union

// Efficient union by discriminator field
let schema = vld::discriminated_union("type")
    .variant_str("dog", vld::object()
        .field("type", vld::literal("dog"))
        .field("bark", vld::boolean()))
    .variant_str("cat", vld::object()
        .field("type", vld::literal("cat"))
        .field("lives", vld::number().int()));

Intersection

// Input must satisfy both schemas
let schema = vld::intersection(
    vld::string().min(3),
    vld::string().email(),
);

Refine

vld::number().int().refine(|n| n % 2 == 0, "Must be even")

Super Refine

// Produce multiple errors in one check
vld::string().super_refine(|s, errors| {
    if s.len() < 3 {
        errors.push(IssueCode::Custom { code: "short".into() }, "Too short");
    }
    if !s.contains('@') {
        errors.push(IssueCode::Custom { code: "no_at".into() }, "Missing @");
    }
})

Transform

vld::string().transform(|s| s.len())  // String -> usize

Pipe

// Chain schemas: output of first -> input of second
vld::string()
    .transform(|s| s.len())
    .pipe(vld::number().min(3.0))

Preprocess

vld::preprocess(
    |v| match v.as_str() {
        Some(s) => serde_json::json!(s.trim()),
        None => v.clone(),
    },
    vld::string().min(1),
)

Lazy (Recursive)

// Self-referencing schemas for trees, graphs, etc.
fn tree() -> vld::object::ZObject {
    vld::object()
        .field("value", vld::number().int())
        .field("children", vld::array(vld::lazy(tree)))
}

Describe

// Attach metadata (does not affect validation)
vld::string().min(3).describe("User's full name")

Dynamic Object

For runtime-defined schemas (without compile-time type safety):

let obj = vld::object()
    .field("name", vld::string().min(1))
    .field("score", vld::number().min(0.0).max(100.0))
    .strict();    // reject unknown fields
    // .strip()   // remove unknown fields (default)
    // .passthrough()  // keep unknown fields as-is

// Object manipulation
let base = vld::object().field("a", vld::string()).field("b", vld::number());
base.pick(&["a"])          // keep only "a"
base.omit("b")             // remove "b"
base.partial()             // all fields become optional
base.required()            // all fields must not be null (opposite of partial)
base.deep_partial()        // partial (nested objects: apply separately)
base.extend(other_object)  // merge fields from another schema
base.merge(other_object)   // alias for extend
base.catchall(vld::string()) // validate unknown fields with a schema
base.keyof()               // Vec<String> of field names

Per-Field Validation & Lenient Parsing

Use schema_validated! for zero-duplication, or schema! + impl_validate_fields! separately:

use vld::prelude::*;

// Option A: single macro (requires Serialize + Default on field types)
vld::schema_validated! {
    #[derive(Debug, serde::Serialize)]
    pub struct User {
        pub name: String     => vld::string().min(2),
        pub email: String    => vld::string().email(),
        pub age: Option<i64> => vld::number().int().gte(18).optional(),
    }
}

// Option B: separate macros (more control)
// vld::schema! { ... }
// vld::impl_validate_fields!(User { name: String => ..., });

validate_fields — per-field diagnostics

let results = User::validate_fields(r#"{"name": "X", "email": "bad"}"#).unwrap();
for f in &results {
    println!("{}", f);
}
// Output:
//   ✖ name: String must be at least 2 characters (received: "X")
//   ✖ email: Invalid email address (received: "bad")
//   ✔ age: null

parse_lenient — returns a ParseResult<T>

parse_lenient returns a [ParseResult<T>] — a wrapper around the struct and per-field diagnostics. You can inspect it, convert to JSON, or save to a file whenever you want.

let result = User::parse_lenient(r#"{"name": "X", "email": "bad"}"#).unwrap();

// Inspect
println!("valid: {}", result.is_valid());        // false
println!("errors: {}", result.error_count());     // 2
println!("value: {:?}", result.value);            // User { name: "", email: "", age: None }

// Per-field diagnostics
for f in result.fields() {
    println!("{}", f);
}

// Only errors
for f in result.error_fields() {
    println!("{}", f);
}

// Display trait prints a summary
println!("{}", result);

// Convert to JSON string
let json = result.to_json_string().unwrap();

// Save to file at any time
result.save_to_file(std::path::Path::new("output.json")).unwrap();

// Or extract the struct
let user = result.into_value();

ParseResult<T> methods:

Method Description
.value The constructed struct (invalid fields use Default)
.fields() All per-field results (&[FieldResult])
.valid_fields() Only passed fields
.error_fields() Only failed fields
.is_valid() true if all fields passed
.has_errors() true if any field failed
.valid_count() Number of valid fields
.error_count() Number of invalid fields
.save_to_file(path) Serialize to JSON file (requires Serialize)
.to_json_string() Serialize to JSON string
.to_json_value() Serialize to serde_json::Value
.into_value() Consume and return the inner struct
.into_parts() Consume and return (T, Vec<FieldResult>)

Single-Field Extraction

Parse the entire schema first, then extract individual fields from the result. Use parse_lenient + .field("name") to inspect a specific field's validation status — even when other fields are invalid:

use vld::prelude::*;

// Define and register per-field validation
vld::schema! {
    #[derive(Debug, serde::Serialize, Default)]
    pub struct User {
        pub name: String     => vld::string().min(2),
        pub email: String    => vld::string().email(),
        pub age: Option<i64> => vld::number().int().gte(18).optional(),
    }
}
vld::impl_validate_fields!(User {
    name  : String      => vld::string().min(2),
    email : String      => vld::string().email(),
    age   : Option<i64> => vld::number().int().gte(18).optional(),
});

// Strict parse — access fields directly
let user = User::parse(r#"{"name":"Alex","email":"a@b.com","age":30}"#).unwrap();
println!("{}", user.name);  // "Alex"

// Lenient parse — some fields may be invalid
let result = User::parse_lenient(r#"{"name":"X","email":"bad","age":25}"#).unwrap();

// The struct is always available (invalid fields use Default)
println!("{}", result.value.age.unwrap()); // 25 — valid, kept as-is

// Check a specific field
let name_field = result.field("name").unwrap();
println!("{}", name_field);       // ✖ name: String must be at least 2 characters
println!("{}", name_field.is_ok()); // false

let age_field = result.field("age").unwrap();
println!("{}", age_field);        // ✔ age: 25
println!("{}", age_field.is_ok()); // true

Error Formatting

use vld::format::{prettify_error, flatten_error, treeify_error};

match User::parse(bad_input) {
    Err(e) => {
        // Human-readable with markers
        println!("{}", prettify_error(&e));
        // ✖ String must be at least 2 characters
        //   → at .name, received "A"

        // Flat map: field -> Vec<message>
        let flat = flatten_error(&e);
        for (field, msgs) in &flat.field_errors {
            println!("{}: {:?}", field, msgs);
        }

        // Tree structure mirroring the schema
        let tree = treeify_error(&e);
    }
    _ => {}
}

Input Sources

Schemas accept any type implementing VldInput:

// JSON string
User::parse(r#"{"name": "Alex", "email": "a@b.com"}"#)?;

// serde_json::Value
let val = serde_json::json!({"name": "Alex", "email": "a@b.com"});
User::parse(&val)?;

// File path
User::parse(std::path::Path::new("data/user.json"))?;

// Byte slice
User::parse(b"{\"name\": \"Alex\", \"email\": \"a@b.com\"}" as &[u8])?;

Validate Existing Rust Values

Requires the serialize feature.

Instead of only parsing JSON, you can validate any existing Rust value using .validate() and .is_valid(). The value is serialized to JSON internally, then validated against the schema.

On any schema

use vld::prelude::*;

// Validate a Vec
let schema = vld::array(vld::number().int().positive()).min_len(1).max_len(5);
assert!(schema.is_valid(&vec![1, 2, 3]));
assert!(schema.validate(&vec![-1, 0]).is_err());

// Validate a String
let email = vld::string().email();
assert!(email.is_valid(&"user@example.com"));
assert!(!email.is_valid(&"bad"));

// Validate a number
let age = vld::number().int().min(18).max(120);
assert!(age.is_valid(&25));
assert!(!age.is_valid(&10));

// Validate a HashMap
let schema = vld::record(vld::number().positive());
let mut map = std::collections::HashMap::new();
map.insert("score", 95.5);
assert!(schema.is_valid(&map));

On schema! structs

Structs with #[derive(serde::Serialize)] get validate() and is_valid() that check an already-constructed instance against the schema:

use vld::prelude::*;

vld::schema! {
    #[derive(Debug, serde::Serialize)]
    pub struct User {
        pub name: String => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

// Construct a struct normally (bypassing parse)
let user = User {
    name: "A".to_string(),        // too short
    email: "bad".to_string(),     // invalid email
};

// Validate it
assert!(!User::is_valid(&user));
let err = User::validate(&user).unwrap_err();
// err contains: .name: too short, .email: invalid

// Also works with serde_json::Value or any Serialize type
let json = serde_json::json!({"name": "Bob", "email": "bob@test.com"});
assert!(User::is_valid(&json));

impl_rules! — Attach Validation to Existing Structs

Use impl_rules! to add .validate() and .is_valid() to a struct you already have. No need to redefine it — just list the field rules:

use vld::prelude::*;

// No #[derive(Serialize)] or #[derive(Debug)] required
struct Product {
    name: String,
    price: f64,
    quantity: i64,
    tags: Vec<String>,
}

vld::impl_rules!(Product {
    name     => vld::string().min(2).max(100),
    price    => vld::number().positive(),
    quantity => vld::number().int().non_negative(),
    tags     => vld::array(vld::string().min(1)).max_len(10),
});

let p = Product {
    name: "Widget".into(),
    price: 9.99,
    quantity: 5,
    tags: vec!["sale".into()],
};
assert!(p.is_valid());

let bad = Product {
    name: "X".into(),
    price: -1.0,
    quantity: -1,
    tags: vec!["".into()],
};
assert!(!bad.is_valid());
let err = bad.validate().unwrap_err();
for issue in &err.issues {
    let path: String = issue.path.iter().map(|p| p.to_string()).collect();
    println!("{}: {}", path, issue.message);
}
// .name: String must be at least 2 characters
// .price: Number must be positive
// .quantity: Number must be non-negative
// .tags[0]: String must be at least 1 characters

The struct itself does not need Serialize or Debug — each field is serialized individually (standard types like String, f64, Vec<T> already implement Serialize). You can use all schema features inside impl_rules!: with_messages(), type_error(), refine(), etc.

Chain Syntax: .or() / .and()

// Union via method chaining
let schema = vld::string().or(vld::number().int());
// Equivalent to vld::union(vld::string(), vld::number().int())

// Intersection via method chaining
let bounded = vld::string().min(3).and(vld::string().email());
// Input must satisfy both constraints

Custom Schema

Create a schema from any closure:

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_eq!(even.parse("4").unwrap(), 4);
assert!(even.parse("5").is_err());

JSON Schema / OpenAPI Generation

Requires the openapi feature.

Generate JSON Schema (compatible with OpenAPI 3.1) from any vld schema via the JsonSchema trait:

use vld::prelude::*;  // imports JsonSchema trait

// Any individual schema
let js = vld::string().min(2).max(50).email().json_schema();
// {"type": "string", "minLength": 2, "maxLength": 50, "format": "email"}

// Collections
let js = vld::array(vld::number().int().positive()).min_len(1).json_schema();
// {"type": "array", "items": {"type": "integer", ...}, "minItems": 1}

// Modifiers (optional wraps with oneOf)
let js = vld::string().email().optional().json_schema();
// {"oneOf": [{"type": "string", "format": "email"}, {"type": "null"}]}

// Unions → oneOf, Intersections → allOf
let js = vld::union(vld::string(), vld::number()).json_schema();
// {"oneOf": [{"type": "string"}, {"type": "number"}]}

Object field schemas

Use field_schema() (instead of field()) to include full JSON Schema for each property:

let js = vld::object()
    .field_schema("email", vld::string().email().min(5))
    .field_schema("score", vld::number().min(0.0).max(100.0))
    .strict()
    .json_schema();
// {"type": "object", "properties": {"email": {...}, "score": {...}}, ...}

schema! macro — struct-level JSON Schema

Structs defined via schema! automatically get json_schema() and to_openapi_document() class methods:

use vld::prelude::*;

vld::schema! {
    #[derive(Debug)]
    pub struct User {
        pub name: String => vld::string().min(2).max(100),
        pub email: String => vld::string().email(),
        pub age: i64 => vld::number().int().min(0),
    }
}

// Full JSON Schema for the struct
let schema = User::json_schema();
// {
//   "type": "object",
//   "required": ["name", "email", "age"],
//   "properties": {
//     "name": {"type": "string", "minLength": 2, "maxLength": 100},
//     "email": {"type": "string", "format": "email"},
//     "age": {"type": "integer", "minimum": 0}
//   }
// }

// Wrap in a minimal OpenAPI 3.1 document
let doc = User::to_openapi_document();
// {"openapi": "3.1.0", "components": {"schemas": {"User": {...}}}, ...}

Multi-schema OpenAPI document

use vld::json_schema::to_openapi_document_multi;

let doc = to_openapi_document_multi(&[
    ("User", User::json_schema()),
    ("Address", Address::json_schema()),
]);

JsonSchema trait

The trait is implemented for all core types: ZString, ZNumber, ZInt, ZBoolean, ZBytes, ZEnum, ZAny, ZArray, ZRecord, ZSet, ZObject, ZOptional, ZNullable, ZNullish, ZDefault, ZCatch, ZRefine, ZTransform, ZDescribe, ZUnion2, ZUnion3, ZIntersection, NestedSchema.

Custom Error Messages

Error messages are configured at the schema level, not after validation. There are three mechanisms:

1. _msg variants — per-check custom messages

Every validation method has a _msg variant that accepts a custom error message:

use vld::prelude::*;

let schema = vld::string()
    .min_msg(3, "Name must be at least 3 characters")
    .max_msg(50, "Name is too long")
    .email_msg("Please enter a valid email");

let err = schema.parse(r#""ab""#).unwrap_err();
// -> "Name must be at least 3 characters"
// -> "Please enter a valid email"

Available on all string checks (email_msg, url_msg, uuid_msg, ipv4_msg, etc.) and number checks are set via with_messages (see below).

2. type_error() — custom type mismatch message

Override the "Expected X, received Y" message when the input has the wrong JSON type:

use vld::prelude::*;

let schema = vld::string().type_error("This field requires text");
let err = schema.parse("42").unwrap_err();
assert!(err.issues[0].message.contains("This field requires text"));

let schema = vld::number().type_error("Age must be a number");
let schema = vld::number().int().int_error("Whole numbers only");

3. with_messages() — bulk override by check key

Override multiple messages at once using check category keys. The closure receives the key and returns Some(new_message) to replace, or None to keep the original:

use vld::prelude::*;

let schema = vld::string().min(5).max(100).email()
    .with_messages(|key| match key {
        "too_small" => Some("Too short!".into()),
        "too_big" => Some("Too long!".into()),
        "invalid_email" => Some("Bad email!".into()),
        _ => None,
    });

Works on numbers too — great for translations:

use vld::prelude::*;

let schema = vld::number().min(1.0).max(100.0)
    .with_messages(|key| match key {
        "too_small" => Some("Значение должно быть не менее 1".into()),
        "too_big" => Some("Значение не должно превышать 100".into()),
        _ => None,
    });

For integers, the key "not_int" overrides the "not an integer" message:

use vld::prelude::*;

let schema = vld::number().int().min(1).max(10)
    .with_messages(|key| match key {
        "too_small" => Some("Minimum is 1".into()),
        "not_int" => Some("No decimals allowed".into()),
        _ => None,
    });

4. In objects — per-field custom messages

Combine type_error() and with_messages() on individual fields:

use vld::prelude::*;

let schema = vld::object()
    .field("name", vld::string().min(2)
        .type_error("Name must be text")
        .with_messages(|k| match k {
            "too_small" => Some("Name is too short".into()),
            _ => None,
        }))
    .field("age", vld::number().int().min(18)
        .type_error("Age must be a number")
        .with_messages(|k| match k {
            "too_small" => Some("Must be 18 or older".into()),
            _ => None,
        }));

String check keys

Key Check
too_small min
too_big max
invalid_length len
invalid_email email
invalid_url url
invalid_uuid uuid
invalid_regex regex
invalid_starts_with starts_with
invalid_ends_with ends_with
invalid_contains contains
non_empty non_empty
invalid_ipv4 ipv4
invalid_ipv6 ipv6
invalid_base64 base64
invalid_iso_date iso_date
invalid_iso_datetime iso_datetime
invalid_iso_time iso_time
invalid_hostname hostname
invalid_cuid2 cuid2
invalid_ulid ulid
invalid_nanoid nanoid
invalid_emoji emoji

Number check keys

Key Check
too_small min, gt, gte
too_big max, lt, lte
not_positive positive
not_negative negative
not_non_negative non_negative
not_non_positive non_positive
not_finite finite
not_multiple_of multiple_of
not_safe safe
not_int int (ZInt only)

Derive Macro

Enable the derive feature for #[derive(Validate)]:

[dependencies]
vld = { version = "0.1", features = ["derive"] }
use vld::Validate;

#[derive(Debug, Default, serde::Serialize, Validate)]
struct User {
    #[vld(vld::string().min(2).max(50))]
    name: String,
    #[vld(vld::string().email())]
    email: String,
    #[vld(vld::number().int().gte(18).optional())]
    age: Option<i64>,
}

// Generates: vld_parse(), parse_value(), validate_fields(), parse_lenient()
let user = User::vld_parse(r#"{"name": "Alex", "email": "a@b.com"}"#).unwrap();

Derive + utoipa (OpenAPI)

#[derive(Validate)] works with impl_to_schema! from vld-utoipa, including full support for #[serde(rename_all = "...")]. Enable both derive and openapi features:

[dependencies]
vld = { version = "0.1", features = ["derive", "openapi"] }
vld-utoipa = "0.1"
utoipa = "5"
use vld::Validate;
use vld_utoipa::impl_to_schema;

#[derive(Debug, serde::Deserialize, Validate)]
#[serde(rename_all = "camelCase")]
struct UpdateLocationRequest {
    #[vld(vld::string().min(1).max(255))]
    name: String,
    #[vld(vld::string())]
    city: String,
    #[vld(vld::string())]
    street_address: String,
    #[vld(vld::number().int().non_negative().min(1).max(9999))]
    street_number: i64,
    #[vld(vld::string().optional())]
    street_number_addition: Option<String>,
    #[vld(vld::boolean())]
    is_active: bool,
}

impl_to_schema!(UpdateLocationRequest);
// OpenAPI schema uses camelCase keys: "streetAddress", "streetNumber", etc.
// Validation also expects camelCase JSON input.

Optional Regex Support

Requires the regex feature.

By default, vld validates all string formats (email, UUID, etc.) without regex. If you need custom regex patterns via .regex(), enable the regex feature:

[dependencies]
vld = { version = "0.1", features = ["regex"] }
let schema = vld::string().regex(vld::regex_lite::Regex::new(r"^\d{3}-\d{4}$").unwrap());

Running the Playground

cargo run --example playground

Benchmarks

cargo bench

Full CI Locally

Run the same high-level checks as CI with one command:

bash scripts/ci-all.sh

Workspace Crates

The vld project is organized as a Cargo workspace with several crates:

Crate Version Description
vld crates.io Core validation library — schemas, parsers, macros, error handling, i18n
vld-derive crates.io Procedural macro #[derive(Validate)] for automatic struct validation
vld-axum crates.io Axum — extractors VldJson, VldQuery, VldPath, VldForm, VldHeaders, VldCookie
vld-actix crates.io Actix-web — extractors VldJson, VldQuery, VldPath, VldForm, VldHeaders, VldCookie
vld-rocket crates.io Rocket — extractors VldJson, VldQuery, VldForm + JSON error catchers
vld-poem crates.io Poem — extractors VldJson, VldQuery, VldForm, VldPath, VldHeaders, VldCookie
vld-warp crates.io Warp — filters vld_json, vld_query, vld_param, vld_path + handle_rejection
vld-salvo crates.io Salvo — extractors VldJson, VldQuery, VldPath, VldForm, VldHeaders, VldCookie
vld-tower crates.io Universal Tower middleware — validate JSON bodies in any Tower-compatible framework
vld-diesel crates.io Diesel ORM — Validated<S, T> wrapper, VldText, VldInt column types
vld-sea crates.io SeaORM — validate ActiveModel before insert/update
vld-utoipa crates.io utoipa — auto-generate ToSchema from vld definitions
vld-aide crates.io aide / schemars — auto-generate JsonSchema from vld definitions
vld-config crates.io Config validation — TOML/YAML/JSON/ENV via config-rs or figment
vld-clap crates.io Clap — validate CLI arguments via #[derive(Validate)]
vld-tauri crates.io Tauri — validate IPC commands, events, state, channels, plugin config
vld-ts crates.io TypeScript codegen — generates Zod schemas from vld JSON Schema output
vld-fake crates.io Fake data generation — User::fake(), fake_many(), fake_seeded() with realistic dictionaries
vld-sqlx crates.io SQLx — validate before insert/update, typed column wrappers
vld-tonic crates.io tonic gRPC — validate protobuf messages and metadata
vld-leptos crates.io Leptos — shared validation for server functions and WASM clients
vld-dioxus crates.io Dioxus — shared validation for server functions and WASM clients
vld-ntex crates.io ntex — extractors VldJson, VldQuery, VldPath, VldForm, VldHeaders, VldCookie
vld-surrealdb crates.io SurrealDB — validate JSON documents before create/insert/update and after select
vld-redis crates.io Redis — validate payloads before set/publish and after get/subscribe
vld-lapin crates.io lapin / RabbitMQ — validate AMQP message payloads before publish and after consume
vld-schemars crates.io schemars — bidirectional bridge between vld and schemars JSON Schema
vld-http-common crates.io Shared HTTP helpers — query parsing, value coercion, error formatting (used by web crates)

vld-derive

Enable with features = ["derive"]. Provides #[derive(Validate)] with #[vld(...)] attributes on struct fields. Supports #[serde(rename)] and #[serde(rename_all)] for JSON key mapping. When openapi feature is also enabled, generates json_schema() and to_openapi_document() methods, making it fully compatible with vld-utoipa's impl_to_schema!.

use vld::Validate;

#[derive(Debug, Default, serde::Serialize, Validate)]
struct User {
    #[vld(vld::string().min(2).max(50))]
    name: String,
    #[vld(vld::string().email())]
    email: String,
}

vld-axum

Validation extractors for Axum web framework. Automatically validates request data and returns 422 Unprocessable Entity with structured JSON errors on failure.

[dependencies]
vld-axum = "0.1"
use vld_axum::prelude::*;

async fn handler(VldJson(body): VldJson<MySchema>) -> impl IntoResponse {
    // body is already validated
}

vld-actix

Validation extractors for Actix-web framework. Same API surface as vld-axum.

[dependencies]
vld-actix = "0.1"
use vld_actix::prelude::*;

async fn handler(VldJson(body): VldJson<MySchema>) -> impl Responder {
    // body is already validated
}

vld-ntex

Validation extractors for ntex web framework (by the author of Actix). Same API surface as vld-actix.

[dependencies]
vld-ntex = "0.1"
ntex = "3"
use ntex::web::HttpResponse;
use vld_ntex::prelude::*;

async fn handler(body: VldJson<MySchema>) -> HttpResponse {
    HttpResponse::Ok().body(format!("name={}", body.name))
}

vld-diesel

Integration with Diesel ORM. Validate data before insert/update, validate rows after load, and use validated column types (VldText<S>, VldInt<S>) that enforce constraints at the type level.

[dependencies]
vld-diesel = { version = "0.1", features = ["sqlite"] }
use vld_diesel::prelude::*;

let validated = validate_insert::<MySchema, _>(&new_row)?;
diesel::insert_into(table).values(&validated.inner).execute(&mut conn)?;

vld-tower

Universal Tower middleware for JSON body validation. Works with any Tower-compatible framework (Axum, Hyper, Tonic, Warp).

[dependencies]
vld-tower = "0.1"
use vld_tower::{ValidateJsonLayer, validated};
use tower::ServiceBuilder;

vld::schema! {
    #[derive(Debug, Clone)]
    pub struct CreateUser {
        pub name: String  => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

// One layer covers all routes
let svc = ServiceBuilder::new()
    .layer(ValidateJsonLayer::<CreateUser>::new())
    .service_fn(handler);

// In handler — zero-cost extraction from extensions
let user: CreateUser = validated(&req);

vld-config

Validate configuration files at load time. Supports config-rs (TOML, YAML, JSON, ENV) and figment.

[dependencies]
vld = "0.1"
vld-config = "0.1"  # config-rs by default
# vld-config = { version = "0.1", features = ["figment"] }
use vld_config::prelude::*;

vld::schema! {
    #[derive(Debug)]
    pub struct Settings {
        pub host: String => vld::string().min(1),
        pub port: i64    => vld::number().int().min(1).max(65535),
    }
}

let config = config::Config::builder()
    .add_source(config::File::with_name("config"))
    .add_source(config::Environment::with_prefix("APP"))
    .build().unwrap();

let settings: Settings = from_config(&config).unwrap();

vld-utoipa

Bridge between vld and utoipa. Define validation once, get ToSchema for free — no need to duplicate schema definitions. Works with both vld::schema! and #[derive(Validate)].

[dependencies]
vld = { version = "0.1", features = ["openapi"] }
vld-utoipa = "0.1"
utoipa = "5"
use vld::prelude::*;
use vld_utoipa::impl_to_schema;

// Option A: schema! macro
vld::schema! {
    #[derive(Debug)]
    pub struct CreateUser {
        pub name: String => vld::string().min(2).max(100),
        pub email: String => vld::string().email(),
    }
}
impl_to_schema!(CreateUser);

// Option B: derive macro (requires features = ["derive", "openapi"])
#[derive(Debug, serde::Deserialize, vld::Validate)]
#[serde(rename_all = "camelCase")]
struct ApiRequest {
    #[vld(vld::string().min(1))]
    first_name: String,
    #[vld(vld::string().email())]
    email_address: String,
}
impl_to_schema!(ApiRequest);
// OpenAPI schema uses camelCase: "firstName", "emailAddress"

Nested schemas are automatically registered in utoipa's components/schemas:

vld::schema! {
    pub struct Address {
        pub city: String => vld::string().min(1),
        pub zip: String  => vld::string().min(5).max(10),
    }
}
impl_to_schema!(Address);

vld::schema! {
    pub struct Order {
        pub name: String    => vld::string().min(1),
        pub address: Address => vld::nested!(Address),
    }
}
impl_to_schema!(Order);
// Order.address → { "$ref": "#/components/schemas/Address" }
// Address schema is auto-registered — no manual listing needed

vld-aide

Bridge between vld and aide / schemars. Define validation once, get JsonSchema for free — no need to duplicate with #[derive(JsonSchema)]. Works with both vld::schema! and #[derive(Validate)].

[dependencies]
vld = { version = "0.1", features = ["openapi"] }
vld-aide = "0.1"
aide = { version = "0.15", features = ["axum"] }
use vld::prelude::*;
use vld_aide::impl_json_schema;

vld::schema! {
    #[derive(Debug)]
    pub struct CreateUser {
        pub name: String => vld::string().min(2).max(100),
        pub email: String => vld::string().email(),
    }
}
impl_json_schema!(CreateUser);
// Now usable with aide::axum::Json<CreateUser> for OpenAPI 3.1 docs.

vld-schemars

General-purpose bidirectional bridge between vld and schemars. Unlike vld-aide (which targets aide specifically), vld-schemars works with any schemars-based library (paperclip, okapi, dropshot, etc.). Provides conversion in both directions, introspection, comparison, and schema merge utilities.

[dependencies]
vld = { version = "0.1", features = ["openapi"] }
vld-schemars = "0.1"
use vld::prelude::*;
use vld_schemars::impl_json_schema;

vld::schema! {
    #[derive(Debug)]
    pub struct User {
        pub name: String  => vld::string().min(2).max(50),
        pub email: String => vld::string().email(),
    }
}
impl_json_schema!(User);

// schemars → vld
let schema = vld_schemars::generate_from_schemars::<String>();

// Introspection
let props = vld_schemars::list_properties(&User::json_schema());
assert!(vld_schemars::is_required(&User::json_schema(), "name"));

vld-rocket

Rocket integration with validation extractors and JSON error catchers.

[dependencies]
vld-rocket = "0.1"
rocket = { version = "0.5", features = ["json"] }
use vld_rocket::prelude::*;

vld::schema! {
    #[derive(Debug, Clone)]
    pub struct CreateUser {
        pub name: String  => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

#[rocket::post("/users", data = "<user>")]
fn create_user(user: VldJson<CreateUser>) -> rocket::serde::json::Json<serde_json::Value> {
    rocket::serde::json::Json(serde_json::json!({"name": user.name}))
}

vld-poem

Poem integration — extractors VldJson, VldQuery, VldForm.

[dependencies]
vld-poem = "0.1"
poem = "3"
use poem::handler;
use vld_poem::prelude::*;

vld::schema! {
    #[derive(Debug, Clone)]
    pub struct CreateUser {
        pub name: String  => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

#[handler]
async fn create_user(user: VldJson<CreateUser>) -> poem::web::Json<serde_json::Value> {
    poem::web::Json(serde_json::json!({"name": user.name}))
}

vld-warp

Warp integration — filters vld_json, vld_query + handle_rejection.

[dependencies]
vld-warp = "0.1"
warp = "0.3"
use vld_warp::prelude::*;
use warp::Filter;

vld::schema! {
    #[derive(Debug, Clone)]
    pub struct CreateUser {
        pub name: String  => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

let route = warp::post()
    .and(warp::path("users"))
    .and(vld_json::<CreateUser>())
    .map(|u: CreateUser| warp::reply::json(&serde_json::json!({"name": u.name})))
    .recover(handle_rejection);

vld-sea

SeaORM integration — validate ActiveModel before insert/update.

[dependencies]
vld-sea = "0.1"
sea-orm = "1"
use sea_orm::Set;
use vld_sea::prelude::*;

vld::schema! {
    #[derive(Debug, Clone)]
    pub struct UserInput {
        pub name: String  => vld::string().min(1).max(100),
        pub email: String => vld::string().email(),
    }
}

let am = user::ActiveModel {
    name: Set("Alice".to_owned()),
    email: Set("alice@example.com".to_owned()),
    ..Default::default()
};

// Validate before insert
vld_sea::validate_active::<UserInput, _>(&am)?;

// Or hook into before_save automatically:
// vld_sea::impl_vld_before_save!(ActiveModel, UserInput);

vld-clap

Clap integration — validate CLI arguments via #[derive(Validate)] directly on the struct.

[dependencies]
vld-clap = "0.1"
vld = { version = "0.1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
use clap::Parser;
use vld::Validate;
use vld_clap::prelude::*;

#[derive(Parser, Debug, serde::Serialize, Validate)]
struct Cli {
    #[arg(long)]
    #[vld(vld::string().email())]
    email: String,
    #[arg(long, default_value_t = 8080)]
    #[vld(vld::number().int().min(1).max(65535))]
    port: i64,
}

fn main() {
    let cli = Cli::parse();
    validate_or_exit(&cli);
    println!("email={}, port={}", cli.email, cli.port);
}

vld-salvo

Salvo integration — extractors implement Extractible and work as #[handler] function parameters, just like Salvo's built-in JsonBody or PathParam.

[dependencies]
vld-salvo = "0.1"
salvo = "0.89"
use salvo::prelude::*;
use vld_salvo::prelude::*;

vld::schema! {
    #[derive(Debug, Clone, serde::Serialize)]
    pub struct CreateUser {
        pub name: String  => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

#[handler]
async fn create(body: VldJson<CreateUser>, res: &mut Response) {
    res.render(Json(serde_json::json!({"name": body.name})));
}

vld-tauri

Tauri IPC validation — commands, events, state, channels, plugin config. Zero dependency on tauri — only vld + serde + serde_json.

[dependencies]
vld-tauri = "0.1"
tauri = "2"
use vld_tauri::prelude::*;

vld::schema! {
    #[derive(Debug, Clone, serde::Serialize)]
    pub struct CreateUser {
        pub name: String  => vld::string().min(2),
        pub email: String => vld::string().email(),
    }
}

// Pattern 1 — explicit
#[tauri::command]
fn create_user(payload: serde_json::Value) -> Result<String, VldTauriError> {
    let user = validate::<CreateUser>(payload)?;
    Ok(format!("Created {}", user.name))
}

// Pattern 2 — auto-validated
#[tauri::command]
fn create_user2(payload: VldPayload<CreateUser>) -> Result<String, VldTauriError> {
    Ok(format!("Created {}", payload.name))
}

vld-fake

Generate fake / test data that satisfies vld validation schemas. Define rules once — get instant, constraint-aware random data for tests, seed scripts, and demos.

[dependencies]
vld = { version = "0.1", features = ["openapi"] }
vld-fake = "0.1"
use vld::prelude::*;
use vld_fake::prelude::*;

vld::schema! {
    #[derive(Debug, Clone, serde::Serialize)]
    pub struct User {
        pub name:  String => vld::string().min(2).max(50),
        pub email: String => vld::string().email(),
        pub age:   i64    => vld::number().int().min(18).max(99),
    }
}

vld_fake::impl_fake!(User);

// Typed access — user.name, user.email, user.age
let user = User::fake();
println!("{} <{}> age={}", user.name, user.email, user.age);

// Multiple + reproducible
let users = User::fake_many(10);
let same  = User::fake_seeded(42);

vld-sqlx

SQLx integration — validate before insert/update, typed column wrappers with Type/Encode/Decode. Supports SQLite, PostgreSQL, MySQL. Generic trait impls work with any backend.

[dependencies]
vld-sqlx = "0.1"
vld = { version = "0.1", features = ["serialize"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
use vld_sqlx::prelude::*;

vld::schema! {
    #[derive(Debug)]
    pub struct UserSchema {
        pub name: String  => vld::string().min(1).max(100),
        pub email: String => vld::string().email(),
    }
}

#[derive(serde::Serialize)]
struct NewUser { name: String, email: String }

let user = NewUser { name: "Alice".into(), email: "alice@example.com".into() };
validate_insert::<UserSchema, _>(&user)?;

// VldText — validated column type, bindable in sqlx queries
let email = vld_sqlx::VldText::<EmailField>::new("alice@test.com")?;
sqlx::query("INSERT INTO users (email) VALUES (?)")
    .bind(&email)
    .execute(&pool).await?;

vld-surrealdb

SurrealDB integration — validate JSON documents before create/insert/update and after select. Zero dependency on the surrealdb crate — works with any SDK version.

[dependencies]
vld-surrealdb = "0.1"
surrealdb = "2"  # or "3"
use vld_surrealdb::prelude::*;

vld::schema! {
    #[derive(Debug)]
    pub struct PersonSchema {
        pub name: String  => vld::string().min(1).max(100),
        pub email: String => vld::string().email(),
        pub age: i64      => vld::number().int().min(0).max(150),
    }
}

#[derive(serde::Serialize)]
struct Person { name: String, email: String, age: i64 }

let person = Person { name: "Alice".into(), email: "alice@example.com".into(), age: 30 };
validate_content::<PersonSchema, _>(&person)?;
// db.create("person").content(person).await?;

vld-tonic

tonic gRPC integration — validate protobuf messages and metadata.

[dependencies]
vld-tonic = "0.1"
use serde::Serialize;
use tonic::{Request, Response, Status};

#[derive(Clone, Serialize)]
pub struct CreateUserRequest {
    pub name: String,
    pub email: String,
}

vld_tonic::impl_validate!(CreateUserRequest {
    name  => vld::string().min(2).max(100),
    email => vld::string().email(),
});

async fn create_user(request: Request<CreateUserRequest>) -> Result<Response<()>, Status> {
    let msg = vld_tonic::validate(request)?;
    Ok(Response::new(()))
}

vld-leptos

Leptos integration — define validation rules once, use on server and client (WASM). Zero dependency on leptos — works with any Leptos version.

[dependencies]
vld-leptos = "0.1"
// Shared schemas (server + WASM)
fn name_schema() -> vld::primitives::ZString { vld::string().min(2).max(50) }
fn email_schema() -> vld::primitives::ZString { vld::string().email() }

// Server function — validate_args! macro
#[server]
async fn create_user(name: String, email: String) -> Result<(), ServerFnError> {
    vld_leptos::validate_args! {
        name  => name_schema(),
        email => email_schema(),
    }.map_err(|e| ServerFnError::new(e.to_string()))?;
    Ok(())
}

// Client component — reactive check_field
let name_err = Memo::new(move |_| {
    vld_leptos::check_field(&name.get(), &name_schema())
});

vld-dioxus

Dioxus integration — define validation rules once, use on server and client (WASM). Zero dependency on dioxus — works with any Dioxus version.

[dependencies]
vld-dioxus = "0.1"
// Shared schemas (server + WASM)
fn name_schema() -> vld::primitives::ZString { vld::string().min(2).max(50) }
fn email_schema() -> vld::primitives::ZString { vld::string().email() }

// Server function — validate_args! macro
#[server]
async fn create_user(name: String, email: String) -> Result<(), ServerFnError> {
    vld_dioxus::validate_args! {
        name  => name_schema(),
        email => email_schema(),
    }.map_err(|e| ServerFnError::new(e.to_string()))?;
    Ok(())
}

// Client component — reactive check_field
let name_err = use_memo(move || {
    vld_dioxus::check_field(&name(), &name_schema())
});

vld-ts

Generate TypeScript Zod schemas from JSON Schema output produced by vld. Useful for sharing validation contracts between Rust backend and TypeScript frontend.

[dependencies]
vld-ts = "0.1"
use vld_ts::generate_zod;

let json_schema = serde_json::json!({
    "type": "object",
    "required": ["name", "email"],
    "properties": {
        "name": { "type": "string", "minLength": 2 },
        "email": { "type": "string", "format": "email" }
    }
});
let ts_code = generate_zod("User", &json_schema);
// Output: export const UserSchema = z.object({ name: z.string().min(2), email: z.string().email() });

License

MIT