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.
- Zero-cost schema definitions — the
schema!macro generates plain Rust structs with built-inparse()methods. Or useschema_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 primitives —
decimal(featuredecimal),duration,path,bytes,file(featurefile). - Extended file validation —
file-advancedenables 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
_msgvariant 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). - Unions —
union(a, b),union3(a, b, c),.or(),discriminated_union("field"),intersection(a, b),.and(). - Recursive schemas —
lazy()for self-referencing data structures (trees, graphs). - Dynamic objects —
strict(),strip(),passthrough(),pick(),omit(),extend(),merge(),partial(),required(),catchall(),keyof(). - Custom schemas —
vld::custom(|v| ...)for arbitrary validation logic. - Multiple input sources — parse from
&str,String,&[u8],Path,PathBuf, orserde_json::Value. - Validate existing values —
.validate(&value)and.is_valid(&value)work with anySerializetype.schema!structs getStruct::validate(&instance). - Lenient parsing —
parse_lenient()returnsParseResult<T>with the struct, per-field diagnostics, and.save_to_file(). - Error formatting —
prettify_error,flatten_error,treeify_errorutilities. - Custom error messages —
_msgvariants,type_error(), andwith_messages()for per-check and bulk message overrides, including translations. - JSON Schema / OpenAPI —
JsonSchematrait on all schema types;json_schema()andto_openapi_document()onschema!structs;field_schema()for rich object property schemas;to_openapi_document_multi()helper. - Derive macro —
#[derive(Validate)]with#[vld(...)]attributes (optionalderivefeature). - 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.
Add to your Cargo.toml:
[dependencies]
vld = "0.4"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 with chrono parsing (NaiveDate, DateTime<Utc>) |
jiff |
Same ZDate / ZDateTime API with jiff parsing (civil::Date, Timestamp). Alternative to chrono. |
time |
Same API with time parsing (time::Date, time::OffsetDateTime UTC). Alternative to chrono / jiff. |
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.4", features = ["serialize", "openapi"] }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);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();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, basic cron syntax)
.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 stringvld::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 numberlet price = vld::decimal()
.min("0.00")
.max("999999.99")
.non_negative();let timeout = vld::duration()
.min_secs(1)
.max_secs(30);
// accepts: 10, "10s", "250ms", "PT10S"let cfg = vld::path().exists().file().absolute();
let dir = vld::path().exists().dir();vld::number().int()
.min(0)
.max(100)
.gte(18)
.positive()
.non_positive()vld::bytes()
.min_len(1)
.max_len(1024)
.len(32)
.non_empty()
.base64() // parse Base64 string
.base64url() // parse Base64URL string
.hex() // parse hex stringlet price = vld::decimal().min("0.00").max("99999.99").non_negative();let timeout = vld::duration().min_secs(1).max_secs(30);
// accepts: 10, "10s", "250ms", "PT10S"let cfg = vld::path().exists().file().absolute();
let safe_rel = vld::path().relative().within("/app/config");vld::boolean()
.coerce() // "true"/"false"/"1"/"0" -> boolvld::literal("admin") // exact string match
vld::literal(42i64) // exact integer match
vld::literal(true) // exact boolean matchvld::enumeration(&["admin", "user", "moderator"])vld::any() // accepts any JSON valueEnable one backend in production: chrono, jiff, or time. The builder API is the same (vld::date(), vld::datetime()). If multiple flags are on (e.g. --all-features), priority is chrono > jiff > time.
| Feature | vld::date() output |
vld::datetime() output |
|---|---|---|
chrono |
chrono::NaiveDate |
chrono::DateTime<chrono::Utc> |
jiff |
jiff::civil::Date |
jiff::Timestamp |
time |
time::Date |
time::OffsetDateTime (UTC) |
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);chrono (features = ["chrono", ...]):
vld::schema! {
pub struct Event {
pub date: chrono::NaiveDate => vld::date().min("2020-01-01"),
pub at: chrono::DateTime<chrono::Utc> => vld::datetime(),
}
}jiff (features = ["jiff", ...]):
vld::schema! {
pub struct Event {
pub date: jiff::civil::Date => vld::date().min("2020-01-01"),
pub at: jiff::Timestamp => vld::datetime().past(),
}
}time (features = ["time", ...]):
vld::schema! {
pub struct Event {
pub date: time::Date => vld::date().min("2020-01-01"),
pub at: time::OffsetDateTime => vld::datetime().past(),
}
}// 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"))?;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);// 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())vld::array(vld::string().non_empty())
.min_len(1)
.max_len(10)
.len(5) // exact length
.non_empty() // alias for min_len(1)// 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();vld::record(vld::number().int().positive())
.min_keys(1)
.max_keys(10)// Input: [["a", 1], ["b", 2]] -> HashMap
vld::map(vld::string(), vld::number().int())// Input: ["a", "b", "a"] -> HashSet {"a", "b"}
vld::set(vld::string().min(1))
.min_size(1)
.max_size(10)// 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>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());// 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()));// Input must satisfy both schemas
let schema = vld::intersection(
vld::string().min(3),
vld::string().email(),
);vld::number().int().refine(|n| n % 2 == 0, "Must be even")// 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 @");
}
})vld::string().transform(|s| s.len()) // String -> usize// Chain schemas: output of first -> input of second
vld::string()
.transform(|s| s.len())
.pipe(vld::number().min(3.0))vld::preprocess(
|v| match v.as_str() {
Some(s) => serde_json::json!(s.trim()),
None => v.clone(),
},
vld::string().min(1),
)// 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)))
}// Attach metadata (does not affect validation)
vld::string().min(3).describe("User's full name")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 namesUse 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 => ..., });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: nullparse_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>) |
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()); // trueuse 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);
}
_ => {}
}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])?;Requires the
serializefeature.
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.
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));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));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 charactersThe 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.
// 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 constraintsCreate 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());Requires the
openapifeature.
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"}]}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": {...}}, ...}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": {...}}}, ...}use vld::json_schema::to_openapi_document_multi;
let doc = to_openapi_document_multi(&[
("User", User::json_schema()),
("Address", Address::json_schema()),
]);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.
Error messages are configured at the schema level, not after validation. There are three mechanisms:
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).
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");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,
});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,
}));| 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 |
| 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) |
Enable the derive feature for #[derive(Validate)]:
[dependencies]
vld = { version = "0.4", 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(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.4", features = ["derive", "openapi"] }
vld-utoipa = "0.4"
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.Query and path parameters use the same impl_to_schema! bridge. Mark the struct with
utoipa's #[into_params(parameter_in = Query)] or Path (requires utoipa in dependencies):
use vld::Validate;
use vld_utoipa::impl_to_schema;
#[derive(Debug, serde::Deserialize, Validate)]
#[into_params(parameter_in = Query)]
struct SearchQuery {
#[vld(vld::string().min(1).max(200))]
q: String,
}
impl_to_schema!(SearchQuery);
// Use in #[utoipa::path(get, path = "/search", params(SearchQuery))]With vld::schema!, the #[into_params] attribute is stripped from the emitted struct
(so utoipa is not required at compile time). With #[derive(Validate)], the attribute
stays on the struct like a normal utoipa derive helper.
See vld-utoipa README for migration from older macros
(impl_to_schema_query!, impl_into_params!(T, Query), etc.).
Requires the
regexfeature.
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.4", features = ["regex"] }let schema = vld::string().regex(vld::regex_lite::Regex::new(r"^\d{3}-\d{4}$").unwrap());cargo run --example playgroundcargo benchRun the same high-level checks as CI with one command:
bash scripts/ci-all.shUse full links below (GitHub, crates.io, docs.rs). This avoids relative-link issues after publication.
| Crate | Version | GitHub | crates.io | docs.rs | Description |
|---|---|---|---|---|---|
vld |
GitHub | crates.io | docs.rs | Core validation library: schemas, parsers, macros, errors, i18n | |
vld-derive |
GitHub | crates.io | docs.rs | Proc-macro #[derive(Validate)] for struct validation |
|
vld-axum |
GitHub | crates.io | docs.rs | Axum extractors (VldJson, VldQuery, VldPath, VldForm, etc.) |
|
vld-actix |
GitHub | crates.io | docs.rs | Actix-web extractors (VldJson, VldQuery, VldPath, VldForm, etc.) |
|
vld-rocket |
GitHub | crates.io | docs.rs | Rocket extractors plus JSON error catchers | |
vld-poem |
GitHub | crates.io | docs.rs | Poem extractors (VldJson, VldQuery, VldPath, VldForm, etc.) |
|
vld-warp |
GitHub | crates.io | docs.rs | Warp filters (vld_json, vld_query, vld_param, vld_path) |
|
vld-salvo |
GitHub | crates.io | docs.rs | Salvo extractors (VldJson, VldQuery, VldPath, VldForm, etc.) |
|
vld-tower |
GitHub | crates.io | docs.rs | Universal Tower middleware for JSON validation | |
vld-diesel |
GitHub | crates.io | docs.rs | Diesel ORM wrappers and validated column types | |
vld-sea |
GitHub | crates.io | docs.rs | SeaORM ActiveModel validation hooks | |
vld-utoipa |
GitHub | crates.io | docs.rs | utoipa integration (ToSchema from vld) |
|
vld-aide |
GitHub | crates.io | docs.rs | aide/schemars integration (JsonSchema from vld) |
|
vld-config |
GitHub | crates.io | docs.rs | Config validation for TOML/YAML/JSON/ENV | |
vld-clap |
GitHub | crates.io | docs.rs | Clap argument validation via #[derive(Validate)] |
|
vld-tauri |
GitHub | crates.io | docs.rs | Tauri IPC/event/state/channel/plugin-config validation | |
vld-ts |
GitHub | crates.io | docs.rs | TypeScript codegen (Zod/Valibot/OpenAPI fragments) | |
vld-fake |
GitHub | crates.io | docs.rs | Fake data generation helpers for validated types | |
vld-sqlx |
GitHub | crates.io | docs.rs | SQLx wrappers and pre-insert/update validation | |
vld-tonic |
GitHub | crates.io | docs.rs | tonic gRPC message and metadata validation | |
vld-leptos |
GitHub | crates.io | docs.rs | Shared validation for Leptos server/WASM clients | |
vld-dioxus |
GitHub | crates.io | docs.rs | Shared validation for Dioxus server/WASM clients | |
vld-ntex |
GitHub | crates.io | docs.rs | ntex extractors (VldJson, VldQuery, VldPath, VldForm, etc.) |
|
vld-surrealdb |
GitHub | crates.io | docs.rs | SurrealDB document validation before/after DB operations | |
vld-redis |
GitHub | crates.io | docs.rs | Redis integration with validation and typed payload conversion | |
vld-lapin |
GitHub | crates.io | docs.rs | lapin/RabbitMQ integration for validated AMQP payloads | |
vld-schemars |
GitHub | crates.io | docs.rs | Bidirectional bridge between vld and schemars schemas |
|
vld-http-common |
GitHub | crates.io | docs.rs | Shared HTTP parsing/coercion/error helpers for web crates |
MIT