engineering

Building zod-rs: Bringing Zod's Validation Model to Rust

While building the Axum backends for my projects I kept running into the same gap: I wanted to validate incoming JSON before I deserialized it into a struct, and I wanted that validation to be composable, path-aware, and easy to attach to request types. The validator crate does derive-based validation well, but it works after deserialization. I wanted something earlier in the pipeline — something that looked at serde_json::Value directly and told me exactly what was wrong before I ever touched a struct.

TypeScript’s Zod had exactly that model. So I built zod-rs — a Rust schema validation library inspired by Zod’s API. It’s been published on crates.io since v0.1, and the current release is v0.4.

The problem with existing validation in Rust

The validator crate is the standard choice and it works for a lot of cases. You derive Validate on a struct, annotate fields with #[validate(email)] or #[validate(length(min = 2))], and call .validate() after deserialization. That’s a reasonable workflow.

But it has a few limitations that were friction for what I was building:

No runtime schema construction. Every validation is hardcoded at compile time via attributes. You can’t build a schema programmatically, compose two schemas together, or pass a schema as an argument.

Works post-deserialization. If the JSON body is structurally wrong (wrong types, missing required fields), you get a serde error, not a validation error. The error format is different and you end up with two separate error handling paths.

No path-aware error accumulation. validator returns errors, but tracking the dot-notation path (user.profile.name) to the failing field in a nested structure requires extra work.

What I wanted was what Zod gives you in TypeScript:

const schema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().min(0).max(120).int(),
});

A composable schema object you can build, chain, pass around, and reuse — that validates unknown input (not a typed value) and returns structured errors with paths.

The Schema trait

The core of zod-rs is a single trait:

pub trait Schema<T>: Debug
where
    T: std::fmt::Debug,
{
    fn validate(&self, value: &Value) -> ValidateResult<T>;

    fn parse(&self, value: &Value) -> T {
        match self.validate(value) {
            Ok(result) => result,
            Err(errors) => panic!("Validation failed: {errors}"),
        }
    }

    fn safe_parse(&self, value: &Value) -> ValidateResult<T> {
        self.validate(value)
    }

    fn optional(self) -> OptionalSchema<Self, T> where Self: Sized { ... }
    fn array(self) -> ArraySchema<Self, T> where Self: Sized { ... }
}

validate is the one method implementations need to provide. parse and safe_parse are provided — parse panics on failure (good for tests and fixtures), safe_parse returns a Result (what you use in production). The optional and array combinators wrap any schema in an OptionalSchema or ArraySchema automatically.

There are 10 concrete schema types: string, number, boolean, null, literal, object, array, optional, union, and tuple. Each is a struct with a builder API. Building a schema for a user registration payload looks like this:

use zod_rs::prelude::*;

let schema = object()
    .field("name", string().min(2).max(50))
    .field("email", string().email())
    .field("age", number().min(0.0).max(120.0).int());

match schema.safe_parse(&json_value) {
    Ok(validated) => { /* proceed */ }
    Err(errors) => { /* return 422 */ }
}

The builder methods are owned — each call consumes self and returns a new Self — so the chain is move-based, no &mut self threading required.

The derive macro — #[derive(ZodSchema)]

For the common case of a struct that maps to a JSON request body, writing the builder by hand is boilerplate. The ZodSchema derive macro generates it for you.

Given this struct from the aysiem API:

#[derive(Debug, Deserialize, ZodSchema)]
pub struct RegisterRequest {
    #[zod(min_length(2), max_length(100))]
    pub tenant_name: String,
    #[zod(min_length(2), max_length(50), regex(r"^[a-z0-9-]+$"))]
    pub tenant_slug: String,
    #[zod(email)]
    pub email: String,
    #[zod(min_length(8), max_length(128))]
    pub password: String,
    #[zod(min_length(1), max_length(100))]
    pub full_name: String,
}

The macro expands to an impl RegisterRequest block with four methods:

  • schema() — returns the composed ObjectSchema for this type
  • validate_and_parse(value: &Value) -> Result<Self, ValidationResult> — validates then deserializes
  • from_json(json_str: &str) -> Result<Self, ParseError> — parses a JSON string end-to-end
  • validate_json(json_str: &str) -> Result<Value, ParseError> — validates a JSON string, returns the raw value

The macro handles Option<T> fields as optional schema fields, Vec<T> as array schemas, integer types (i32, u64, etc.) automatically get .int(), and nested structs call their own .schema() — so you can compose validated types.

Supported #[zod(...)] attributes: min_length, max_length, length, min, max, email, url, regex, starts_with, ends_with, includes, positive, negative, nonnegative, nonpositive, int, finite.

Error handling — path-aware validation

When validation fails, you get a ValidationResult — a collection of ValidationIssue values, each with a path and an error:

pub struct ValidationIssue {
    pub path: Vec<String>,
    pub error: ValidationError,
}

The path is a Vec<String> that the display impl joins with dots:

user.email: Invalid email address
user.age: Too small: expected number to have >= 15

There are 11 error variants: Required, InvalidType, InvalidValue, TooBig, TooSmall, InvalidFormat, InvalidLength, InvalidStart, InvalidEnd, InvalidIncludes, and Custom.

The library also has i18n support. The default Display impl produces English messages, and you can call .local(Locale::Ar) on a ValidationResult to get Arabic messages:

user.email: بريد إلكتروني غير مقبول
user.age: أصغر من اللازم: يفترض لـ number أن يكون >= 15

The Locale::En / Locale::Ar support covers all 11 error variants. I added Arabic because aysiem is deployed for Arabic-speaking clients and surface-level error messages matter there.

Axum integration

The axum feature flag enables an Axum extractor pattern. In my projects I define a Validated<T> wrapper in a shared validation crate and use it across all route handlers.

Here’s the actual implementation from the warehouse-management monorepo (wm-validation/src/lib.rs):

pub trait ZodValidate: Sized + serde::de::DeserializeOwned {
    fn zod_validate(value: &serde_json::Value) -> Result<Self, zod_rs_util::ValidationResult>;
}

#[derive(Debug)]
pub struct Validated<T>(pub T);

impl<S, T> FromRequest<S> for Validated<T>
where
    S: Send + Sync,
    T: ZodValidate,
    axum::Json<serde_json::Value>: FromRequest<S, Rejection = JsonRejection>,
{
    type Rejection = Response;

    async fn from_request(
        req: axum::extract::Request,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let axum::Json(value) = axum::Json::<serde_json::Value>::from_request(req, state)
            .await
            .map_err(|e| AppError::BadRequest(e.to_string()).into_response())?;

        let parsed = T::zod_validate(&value)
            .map_err(|e| AppError::Validation(e.to_string()).into_response())?;

        Ok(Validated(parsed))
    }
}

ZodValidate is a thin trait — types that derive ZodSchema and implement ZodValidate (via a macro) plug directly into this extractor. In a route handler:

async fn register(
    Validated(body): Validated<RegisterRequest>,
) -> Result<impl IntoResponse, AppError> {
    // body is already validated and deserialized
}

If the JSON is malformed you get a BadRequest. If it’s valid JSON but fails schema validation you get a Validation error with the full path-aware error list. Both cases are handled before the handler body runs.

This pattern is in production use in two codebases: the warehouse management system and aysiem.

TypeScript codegen — #[derive(ZodTs)]

The ts feature adds a ZodTs derive macro that generates a TypeScript Zod schema from a Rust struct. The idea is that if you have a RegisterRequest on the server, you can emit the equivalent schema on the client without writing it twice.

A Rust struct like this:

#[derive(ZodTs)]
pub struct CreateUserRequest {
    #[zod(min_length(2), max_length(50))]
    pub username: String,
    #[zod(email)]
    pub email: String,
    pub age: Option<u32>,
}

…generates something like:

export const CreateUserRequestSchema = z.object({
  username: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().optional(),
});

export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;

The same validation rules, expressed in both languages, staying in sync because they’re generated from the same source. This is still experimental in v0.4 — the basic types work, but I haven’t wired it into a build step in the frontend yet.

Crate structure

zod-rs is split into four crates:

  • zod-rs — the core library: Schema trait, 10 schema types, builder API, re-exports
  • zod-rs-macros — the proc macro crate: ZodSchema derive, ZodTs derive
  • zod-rs-util — error types, ValidationResult, ValidationIssue, ValidateResult, i18n
  • zod-rs-ts — TypeScript codegen (experimental)

The default feature enables the macros feature. The axum feature pulls in axum and tokio as optional dependencies. The ts feature pulls in zod-rs-ts.

A few design decisions I made that are worth calling out:

JSON-centric validation. Everything validates serde_json::Value. This was intentional — the point is to validate the raw JSON before touching your domain types, not to re-validate them after. If you want post-deserialization validation, validator is still the better tool.

Arc<dyn Schema<Value>> for type erasure. The ObjectSchema stores its fields as Arc<dyn Schema<Value>>, which lets you mix schema types in a single object without fighting the type system. You pay a small allocation and dynamic dispatch per field, which is fine for request validation.

Owned builder methods. fn min(mut self, min: usize) -> Self means each builder call moves, not borrows. Chaining is clean and there’s no lifetime threading. The trade-off is that you can’t incrementally build a schema in a mutable variable without moving it.

What I learned

Proc macros are powerful but the ergonomics of writing them are rough. syn and quote are comprehensive, but debugging macro expansion is painful. cargo expand is essential. I spent more time on the macro than on the core validation logic. The token parsing in parse_zod_attributes is particularly fiddly — I’m manually walking a token stream instead of using a proper parser combinator.

Runtime composability is the real differentiator. The validator crate is more mature and handles more edge cases. But the ability to construct a schema at runtime — to write a function that returns a Schema, or to build a validation schema based on a database config — is something validator doesn’t support. That’s the use case zod-rs is for.

Positioning matters for a library. Early versions of the README tried to compete with validator directly. Once I framed it as “for validating raw JSON before deserialization, inspired by TypeScript’s Zod” the audience became clearer. They’re solving different problems.

v0.4 is stable and I use it in production. TypeScript codegen is there but experimental. The next things I want to add are more format validators — UUID, IPv4, JWT — the stubs for those are already in the i18n message system but the validation logic isn’t wired up yet.

If you’re building JSON APIs in Rust with Axum and want Zod-style validation, give it a try: crates.io/crates/zod-rs, docs at zod-rs.msdqn.dev.