maulanasdqn/zod-rs(opens in a new tab)
Language: Rust
A Zod Implementation in Rust
- stars
- 57
- watchers
- 1
- forks
- 2
- open issues
- 1
- Last updated
- 09/02/2026
Language: Rust
A Zod Implementation in Rust
A Rust implementation inspired by Zod for schema validation
zod-rs is a TypeScript-first schema validation library with static type inference, inspired by Zod. It provides a simple and intuitive API for validating JSON data with comprehensive error reporting.
Full documentation is available at zod.rs.
#[zod(...)] attributesvalidator crateAdd zod-rs to your Cargo.toml:
[dependencies] zod-rs = "1.0" # Optional: for web framework integration zod-rs = { version = "1.0", features = ["axum"] } # For TypeScript Zod schema generation zod-rs = { version = "1.0", features = ["ts"] } # Or use the standalone crate zod-rs-ts = "1.0" # For schema derivation from structs (recommended) zod-rs = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0"
use serde_json::json; use zod_rs::prelude::*; fn main() { // Define a schema let user_schema = object() .field("name", string().min(2).max(50)) .field("email", string().email()) .field("age", number().min(0.0).max(120.0).int()); // Validate data let user_data = json!({ "name": "Alice", "email": "alice@example.com", "age": 25 }); match user_schema.safe_parse(&user_data) { Ok(validated_data) => println!("Valid: {:?}", validated_data), Err(errors) => println!("Invalid: {}", errors), } }
use zod_rs::prelude::*; use serde_json::json; // Basic string let schema = string(); assert!(schema.safe_parse(&json!("hello")).is_ok()); // String with length constraints let schema = string().min(3).max(10); assert!(schema.safe_parse(&json!("hello")).is_ok()); assert!(schema.safe_parse(&json!("hi")).is_err()); // Exact length let schema = string().length(5); assert!(schema.safe_parse(&json!("hello")).is_ok()); // Pattern matching let schema = string().regex(r"^[a-zA-Z]+$"); assert!(schema.safe_parse(&json!("hello")).is_ok()); assert!(schema.safe_parse(&json!("hello123")).is_err()); // Email validation let schema = string().email(); assert!(schema.safe_parse(&json!("user@example.com")).is_ok()); // URL validation let schema = string().url(); assert!(schema.safe_parse(&json!("https://example.com")).is_ok());
use zod_rs::prelude::*; use serde_json::json; // Basic number let schema = number(); assert!(schema.safe_parse(&json!(42.5)).is_ok()); // Integer only let schema = number().int(); assert!(schema.safe_parse(&json!(42)).is_ok()); assert!(schema.safe_parse(&json!(42.5)).is_err()); // Range constraints let schema = number().min(0.0).max(100.0); assert!(schema.safe_parse(&json!(50)).is_ok()); assert!(schema.safe_parse(&json!(-1)).is_err()); // Positive numbers let schema = number().positive(); assert!(schema.safe_parse(&json!(1)).is_ok()); assert!(schema.safe_parse(&json!(0)).is_err()); // Non-negative numbers let schema = number().nonnegative(); assert!(schema.safe_parse(&json!(0)).is_ok()); assert!(schema.safe_parse(&json!(-1)).is_err()); // Finite numbers (excludes NaN, Infinity) let schema = number().finite(); assert!(schema.safe_parse(&json!(42.0)).is_ok());
use zod_rs::prelude::*; use serde_json::json; let schema = boolean(); assert!(schema.safe_parse(&json!(true)).is_ok()); assert!(schema.safe_parse(&json!(false)).is_ok()); assert!(schema.safe_parse(&json!("true")).is_err());
use zod_rs::prelude::*; use serde_json::json; // String literal let schema = literal("active".to_string()); assert!(schema.safe_parse(&json!("active")).is_ok()); assert!(schema.safe_parse(&json!("inactive")).is_err()); // Number literal let schema = literal(42.0); assert!(schema.safe_parse(&json!(42.0)).is_ok()); assert!(schema.safe_parse(&json!(43.0)).is_err());
use zod_rs::prelude::*; use serde_json::json; // Array of strings let schema = array(string()); assert!(schema.safe_parse(&json!(["a", "b", "c"])).is_ok()); // Array with length constraints let schema = array(string()).min(1).max(5); assert!(schema.safe_parse(&json!(["a"])).is_ok()); assert!(schema.safe_parse(&json!([])).is_err()); // Array with exact length let schema = array(number()).length(3); assert!(schema.safe_parse(&json!([1, 2, 3])).is_ok()); // Nested arrays let schema = array(array(string())); assert!(schema.safe_parse(&json!([["a", "b"], ["c", "d"]])).is_ok());
use zod_rs::prelude::*; use serde_json::json; // Simple object let schema = object() .field("name", string()) .field("age", number()); let data = json!({"name": "Alice", "age": 25}); assert!(schema.safe_parse(&data).is_ok()); // Object with optional fields let schema = object() .field("name", string()) .optional_field("bio", string()); let data = json!({"name": "Alice"}); assert!(schema.safe_parse(&data).is_ok()); // Strict mode (no additional properties) let schema = object() .field("name", string()) .strict();
use zod_rs::prelude::*; use serde_json::json; let schema = optional(string()); assert!(schema.safe_parse(&json!(null)).is_ok()); assert!(schema.safe_parse(&json!("hello")).is_ok()); // Method chaining let schema = string().optional();
use zod_rs::prelude::*; use serde_json::json; let schema = union() .variant(string()) .variant(number()); assert!(schema.safe_parse(&json!("hello")).is_ok()); assert!(schema.safe_parse(&json!(42)).is_ok()); assert!(schema.safe_parse(&json!(true)).is_err()); // Literal unions (enums) let schema = union() .variant(literal("small".to_string())) .variant(literal("medium".to_string())) .variant(literal("large".to_string()));
All schemas support these methods:
let schema = string(); let result = schema.parse(&json!("hello")); // Panics on validation failure
let schema = string(); match schema.safe_parse(&json!("hello")) { Ok(value) => println!("Valid: {}", value), Err(errors) => println!("Invalid: {}", errors), }
let schema = string(); let result = schema.validate(&json!("hello"));
use serde::{Deserialize, Serialize}; use serde_json::json; use zod_rs::prelude::*; #[derive(Debug, Serialize, Deserialize)] struct User { username: String, email: String, age: f64, interests: Vec<String>, } fn user_schema() -> impl Schema<Value> { object() .field("username", string().min(3).max(20).regex(r"^[a-zA-Z0-9_]+$")) .field("email", string().email()) .field("age", number().min(13.0).max(120.0).int()) .field("interests", array(string()).min(1).max(10)) } fn main() { let user_data = json!({ "username": "alice_dev", "email": "alice@example.com", "age": 28, "interests": ["rust", "programming"] }); // Validate and deserialize match user_schema().validate(&user_data) { Ok(_) => { let user: User = serde_json::from_value(user_data).unwrap(); println!("Valid user: {:?}", user); } Err(errors) => { println!("Validation failed: {}", errors); } } }
use zod_rs::prelude::*; use serde_json::json; fn address_schema() -> impl Schema<Value> { object() .field("street", string().min(1)) .field("city", string().min(1)) .field("country", string().length(2)) // ISO country code .field("zip", string().regex(r"^\d{5}(-\d{4})?$")) } fn user_schema() -> impl Schema<Value> { object() .field("name", string()) .field("email", string().email()) .field("address", address_schema()) .optional_field("billing_address", address_schema()) } let user_data = json!({ "name": "John Doe", "email": "john@example.com", "address": { "street": "123 Main St", "city": "Boston", "country": "US", "zip": "02101" } }); assert!(user_schema().safe_parse(&user_data).is_ok());
Enable the axum feature in your Cargo.toml:
[dependencies] zod-rs = { version = "1.0", features = ["axum"] } axum = "0.7" tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0"
use axum::{ extract::Json, http::StatusCode, response::{IntoResponse, Json as ResponseJson}, routing::post, Router, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use zod_rs::prelude::*; #[derive(Debug, Serialize, Deserialize)] struct CreateUserRequest { name: String, email: String, age: f64, } #[derive(Serialize)] struct ApiResponse<T> { success: bool, data: Option<T>, errors: Option<Vec<String>>, } fn user_schema() -> impl Schema<Value> { object() .field("name", string().min(2).max(50)) .field("email", string().email()) .field("age", number().min(13.0).max(120.0).int()) } async fn create_user(Json(payload): Json<Value>) -> impl IntoResponse { match user_schema().validate(&payload) { Ok(_) => { let user: CreateUserRequest = serde_json::from_value(payload).unwrap(); ( StatusCode::CREATED, ResponseJson(ApiResponse { success: true, data: Some(user), errors: None, }), ) } Err(validation_result) => { let errors: Vec<String> = validation_result .issues .iter() .map(|issue| issue.to_string()) .collect(); ( StatusCode::BAD_REQUEST, ResponseJson(ApiResponse::<CreateUserRequest> { success: false, data: None, errors: Some(errors), }), ) } } } #[tokio::main] async fn main() { let app = Router::new().route("/users", post(create_user)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
zod-rs provides detailed error information with path tracking:
use zod_rs::prelude::*; use serde_json::json; let schema = object() .field("user", object() .field("name", string().min(2)) .field("email", string().email()) ); let invalid_data = json!({ "user": { "name": "A", "email": "invalid-email" } }); match schema.safe_parse(&invalid_data) { Err(errors) => { println!("{}", errors); // Output: // - user.name: Too big: expected string to have >= 2 characters // - user.email: Invalid email address } _ => {} }
ValidationError::Required - Missing required fieldValidationError::InvalidType - Wrong data typeValidationError::InvalidValue - Provided value does not match the expected valueValidationError::InvalidValues - Provided value does not match any of the expected values.ValidationError::TooSmall / TooBig - Value or lenght out of range (String, Number, Array ... etc)ValidationError::InvalidFormat - String format validation (starts with , ends with, includes, regex, ... etc)ValidationError::InvalidNumber - Invalid number constraint (finite, positive, ... etc)ValidationError::UnrecognizedKeys - Object with unrecognized keysValidationError::InvalidUnion - No union matchingValidationError::Custom - Custom validation errorszod-rs comes with built-in locale support so you can get validation errors in different languages.
Example
use serde_json::json; use zod_rs::prelude::*; let login_schema = object() .field("email", string().ends_with("@domain.com")) .field("password", string().min(8)) .strict(); let input = json!({ "email": "john@example.com", "password": "Strongpassword123" }); match login_schema.safe_parse(&input) { Ok(output) => println!("Valid login: {output}"), Err(err) => println!("{}", err.local(Locale::Ar)), }
Want to add a new language? Missing a translation? Open an issue or PR on GitHub — contributions are welcome.
zod-rs provides a powerful derive macro that automatically generates validation schemas from Rust structs, making it an excellent replacement for the validator crate.
use serde::{Deserialize, Serialize}; use serde_json::json; use zod_rs::prelude::*; #[derive(Debug, Serialize, Deserialize, ZodSchema)] struct User { #[zod(min_length(3), max_length(20), regex(r"^[a-zA-Z0-9_]+$"))] username: String, #[zod(email)] email: String, #[zod(min(13.0), max(120.0), int)] age: u32, #[zod(min_length(1), max_length(10))] interests: Vec<String>, bio: Option<String>, #[zod(nonnegative)] score: f64, is_active: bool, } let user_data = json!({ "username": "alice_dev", "email": "alice@example.com", "age": 28, "interests": ["rust", "programming"], "score": 95.5, "is_active": true }); match User::validate_and_parse(&user_data) { Ok(user) => println!("Valid user: {:?}", user), Err(e) => println!("Invalid: {}", e), } let schema = User::schema(); match schema.validate(&user_data) { Ok(_) => println!("Schema validation passed"), Err(e) => println!("Schema validation failed: {}", e), } let user_from_json = User::from_json(r#"{"username":"test","email":"test@example.com",...}"#)?;
The #[zod(...)] attribute supports the following constraints:
String Validation:
min_length(n) - Minimum string lengthmax_length(n) - Maximum string lengthstarts_with("value") - String starts with a given valueends_with("value") - String ends with a given valueincludes("value") - String includes a given valuelength(n) - Exact string lengthemail - Email format validationurl - URL format validationregex("pattern") - Regular expression pattern matchingNumber Validation:
min(n) - Minimum valuemax(n) - Maximum valueint - Integer only (no decimals)positive - Must be positive (> 0)negative - Must be negative (< 0)nonnegative - Must be non-negative (>= 0)nonpositive - Must be non-positive (<= 0)finite - Must be finite (excludes NaN, Infinity)Array Validation:
min_length(n) - Minimum array lengthmax_length(n) - Maximum array lengthlength(n) - Exact array lengthThe derive macro automatically handles nested structs:
#[derive(Debug, Serialize, Deserialize, ZodSchema)] struct Address { #[zod(min_length(5), max_length(200))] street: String, #[zod(min_length(2), max_length(50))] city: String, #[zod(length(2))] country_code: String, } #[derive(Debug, Serialize, Deserialize, ZodSchema)] struct UserProfile { #[zod(min_length(2), max_length(50))] name: String, #[zod(email)] email: String, address: Option<Address>, }
The ZodSchema derive macro generates the following methods:
schema() - Returns the validation schemavalidate_and_parse(value) - Validates and deserializes JSON valuefrom_json(json_str) - Validates and parses from JSON stringvalidate_json(json_str) - Validates JSON string (returns Value)zod-rs fully supports Rust enums with the ZodSchema derive macro. Enums are validated using the externally-tagged format (serde default): unit variants are plain strings, and variants with data are single-key tagged objects. #[serde(rename)] and #[serde(rename_all)] are honored.
use serde::{Deserialize, Serialize}; use serde_json::json; use zod_rs::prelude::*; // Unit variants #[derive(Debug, Serialize, Deserialize, ZodSchema)] enum Status { Active, Inactive, Pending, } // Tuple variants #[derive(Debug, Serialize, Deserialize, ZodSchema)] enum Message { Text(String), Number(i32), Coords(i32, i32), } // Struct variants #[derive(Debug, Serialize, Deserialize, ZodSchema)] enum Event { Click { x: i32, y: i32 }, Scroll { delta: f64 }, } // Mixed variants #[derive(Debug, Serialize, Deserialize, ZodSchema)] enum ApiResponse { Success, Data(String), Error { code: i32, message: String }, } fn main() { // Unit variant: "Active" let status = json!("Active"); assert!(Status::validate_and_parse(&status).is_ok()); // Tuple variant (single): {"Text": "hello"} let msg = json!({"Text": "hello"}); assert!(Message::validate_and_parse(&msg).is_ok()); // Tuple variant (multiple): {"Coords": [10, 20]} let coords = json!({"Coords": [10, 20]}); assert!(Message::validate_and_parse(&coords).is_ok()); // Struct variant: {"Click": {"x": 100, "y": 200}} let event = json!({"Click": {"x": 100, "y": 200}}); assert!(Event::validate_and_parse(&event).is_ok()); }
| Variant Type | Rust | JSON |
|---|---|---|
| Unit | Status::Active |
"Active" |
| Tuple (single) | Message::Text("hi") |
{"Text": "hi"} |
| Tuple (multiple) | Message::Coords(1, 2) |
{"Coords": [1, 2]} |
| Struct | Event::Click { x: 1, y: 2 } |
{"Click": {"x": 1, "y": 2}} |
The legacy {"Active": null} form is still accepted for unit variants, matching serde's deserialization behavior.
Generate TypeScript Zod schemas from your Rust types using the ZodTs derive macro.
use zod_rs_ts::ZodTs; #[derive(ZodTs)] struct User { #[zod(min_length(2), max_length(50))] username: String, #[zod(email)] email: String, #[zod(min(18.0), max(120.0), int)] age: u32, bio: Option<String>, } fn main() { // Generate TypeScript code let ts_code = User::zod_ts(); println!("{}", ts_code); // Or write to file std::fs::write("schemas/user.ts", ts_code).unwrap(); }
Generated TypeScript:
import * as z from "zod"; export const UserSchema = z.object({ username: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().min(18).max(120), bio: z.string().optional() }); export type User = z.infer<typeof UserSchema>;
The generated schemas target Zod v4 by default (import * as z from "zod"). To emit Zod v3 style imports instead, enable the zod-v3 feature:
[dependencies] zod-rs = { version = "...", features = ["ts", "zod-v3"] } # or, if depending on zod-rs-ts directly: zod-rs-ts = { version = "...", features = ["zod-v3"] }
When using the CLI, pass --zod-version v3 to switch (defaults to v4):
zod-rs-ts generate --input src/ --output schemas/ --zod-version v3
Schemas produced by ZodTs are Standard Schema compliant out of the box — this comes from Zod itself (Zod v3.24+ and all Zod v4 schemas implement the ~standard interface natively). That means the generated output works directly with Standard Schema consumers like TanStack Form, React Hook Form, and other validation-library-agnostic tooling, with no extra wiring required.
#[derive(ZodTs)] enum Status { Active, Inactive, } #[derive(ZodTs)] enum Event { Click { x: i32, y: i32 }, Scroll { delta: f64 }, } fn main() { println!("{}", Status::zod_ts()); println!("{}", Event::zod_ts()); }
Generated TypeScript:
export const StatusSchema = z.union([ z.literal("Active"), z.literal("Inactive") ]); export const EventSchema = z.union([ z.object({ Click: z.object({ x: z.number().int(), y: z.number().int() }) }), z.object({ Scroll: z.object({ delta: z.number() }) }) ]);
Install and use the CLI for batch generation:
# Install with CLI feature cargo install zod-rs-ts --features cli # Generate schemas from Rust source files (emits Zod v4 imports by default) zod-rs-ts generate --input src/ --output schemas/ # Generate all schemas in a single file zod-rs-ts generate --input src/ --output schemas/index.ts --single-file # Target Zod v3 imports instead zod-rs-ts generate --input src/ --output schemas/ --zod-version v3
use zod_rs::prelude::*; use zod_rs_util::{ValidationError, ValidateResult}; use serde_json::Value; struct CustomSchema { min_words: usize, } impl Schema<String> for CustomSchema { fn validate(&self, value: &Value) -> ValidateResult<String> { let string_val = value.as_str() .ok_or_else(|| ValidationError::invalid_type("string", "other"))? .to_string(); let word_count = string_val.split_whitespace().count(); if word_count < self.min_words { return Err(ValidationError::custom( format!("Must contain at least {} words", self.min_words) ).into()); } Ok(string_val) } } let schema = CustomSchema { min_words: 3 }; assert!(schema.safe_parse(&json!("hello world rust")).is_ok()); assert!(schema.safe_parse(&json!("hello world")).is_err());
use zod_rs::prelude::*; fn email_schema() -> impl Schema<String> { string().email() } fn password_schema() -> impl Schema<String> { string().min(8).regex(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)") } fn login_schema() -> impl Schema<Value> { object() .field("email", email_schema()) .field("password", password_schema()) }
Run the test suite:
cargo testRun examples:
# Basic usage cargo run --example basic_usage # Struct validation cargo run --example struct_validation # Derive macro for schema inference cargo run --example derive_schema # Validator crate replacement cargo run --example validator_replacement # TypeScript Zod schema generation cargo run --example zod_ts --features ts # Axum integration cargo run --example axum_usage --features axum
This project uses a Cargo workspace with the following crates:
zod-rs - Main validation library with schema typeszod-rs-macros - Derive macros for ZodSchemazod-rs-ts - TypeScript Zod schema generationzod-rs-util - Utility functions, error handling and i18nContributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
cargo testcargo run --example basic_usagecargo fmtcargo clippyThis project is licensed under either of
at your option.
zod-rs provides significant advantages over the traditional validator crate:
| Feature | zod-rs | validator crate |
|---|---|---|
| Schema Definition | Derive macro with attributes | Struct attributes only |
| Runtime Flexibility | Dynamic schema creation | Compile-time only |
| Error Messages | Detailed with full path context | Basic field-level errors |
| JSON Integration | Built-in JSON validation/parsing | Manual serde integration |
| Nested Validation | Automatic nested struct support | Manual implementation |
| Schema Reuse | Composable and reusable schemas | Struct-bound validation |
| Type Safety | Full type inference | Limited type information |
| Performance | Optimized validation pipeline | Direct field validation |
| Extensibility | Custom validators and schemas | Custom validation functions |
| Framework Integration | Built-in web framework support | Manual integration required |
| Internationalization | Built-in Localized error messages | No i18n support |
// Before: using validator crate use validator::{Validate, ValidationError}; #[derive(Validate)] struct User { #[validate(length(min = 3, max = 20))] username: String, #[validate(email)] email: String, #[validate(range(min = 13, max = 120))] age: u32, } // After: using zod-rs use zod_rs::prelude::*; #[derive(ZodSchema)] struct User { #[zod(min_length(3), max_length(20))] username: String, #[zod(email)] email: String, #[zod(min(13.0), max(120.0), int)] age: u32, } // Enhanced capabilities with zod-rs let user_data = json!({ "username": "alice", "email": "alice@example.com", "age": 25 }); // Validate and parse in one step let user = User::validate_and_parse(&user_data)?; // Or validate JSON string directly let user = User::from_json(r#"{"username":"alice",...}"#)?; // Reuse schema for different purposes let schema = User::schema(); let is_valid = schema.validate(&user_data).is_ok();
Made by Maulana Sodiqin