Skip to content

Share Validation Between Rust and TypeScript

To share validation between a Rust backend and a TypeScript frontend, define your schemas once in Rust with #[derive(ZodTs)], run the zod-rs-ts CLI to generate TypeScript Zod schemas, and import them in your frontend code. Both sides enforce identical rules from a single source of truth — no manual syncing, no validation drift.

In a typical full-stack application the backend and frontend validate the same data independently. The Rust API checks that username is 3–20 characters; the React form checks that username is 2–30 characters. Nobody notices the mismatch until a user submits a value the frontend accepts and the backend rejects — or worse, the frontend blocks a value the backend would allow.

Keeping two rule sets in sync by hand does not scale. Every time a field changes — a new constraint, a renamed property, a new enum variant — someone has to update both sides and hope the PR reviewer catches the ones they missed.

The solution: define once, generate TypeScript

Section titled “The solution: define once, generate TypeScript”

zod-rs solves this with a two-step workflow:

  1. Define validation rules in Rust using #[derive(ZodSchema, ZodTs)] on your types.
  2. Generate matching TypeScript Zod schemas with the zod-rs-ts CLI.

The generated TypeScript code imports from zod and mirrors every field name, type, and constraint from your Rust definition. Your frontend npm run build always validates with the same rules your Rust backend enforces.

Annotate each type with both ZodSchema (for Rust-side validation) and ZodTs (for TypeScript generation):

use zod_rs::prelude::*;
#[derive(ZodSchema, ZodTs, serde::Deserialize, Debug)]
struct SignupRequest {
#[zod(min_length(3), max_length(20))]
username: String,
#[zod(email)]
email: String,
#[zod(min(13.0), max(120.0), int)]
age: u32,
#[zod(min_length(8))]
password: String,
}

Every #[zod(...)] attribute you add is reflected in both the Rust schema and the generated TypeScript schema. See the attributes reference for the full list.

Terminal window
cargo install zod-rs-ts

Point the CLI at your Rust source files:

Terminal window
zod-rs-ts src/models.rs -o frontend/src/schemas/

This produces a file like frontend/src/schemas/models.ts:

import { z } from "zod";
export const SignupRequestSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
age: z.number().int().min(13).max(120),
password: z.string().min(8),
});
export type SignupRequest = z.infer<typeof SignupRequestSchema>;

The generated code is self-contained — it depends only on zod, which your frontend likely already uses.

import { SignupRequestSchema } from "../schemas/models";
function validateSignup(formData: Record<string, unknown>) {
const result = SignupRequestSchema.safeParse(formData);
if (!result.success) {
return result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
}
return result.data; // typed as SignupRequest
}

Now your React form, Vue component, or Svelte page validates with the exact same constraints as your Rust API handler. Change a rule in Rust, regenerate, and the frontend follows automatically.

Here is the complete flow for a signup form validated identically on both sides.

Rust backend (Axum handler):

use axum::{Json, response::IntoResponse};
use zod_rs::prelude::*;
#[derive(ZodSchema, ZodTs, serde::Deserialize, Debug)]
struct SignupRequest {
#[zod(min_length(3), max_length(20))]
username: String,
#[zod(email)]
email: String,
#[zod(min(13.0), max(120.0), int)]
age: u32,
#[zod(min_length(8))]
password: String,
}
async fn signup(body: Json<serde_json::Value>) -> impl IntoResponse {
match SignupRequest::validate_and_parse(&body) {
Ok(req) => { /* create user */ }
Err(errors) => { /* return 422 with errors */ }
}
}

TypeScript frontend (generated):

import { SignupRequestSchema } from "./schemas/models";
const result = SignupRequestSchema.safeParse({
username: "al", // fails: min 3
email: "not-email", // fails: invalid email
age: 10, // fails: min 13
password: "short", // fails: min 8
});
// result.error.issues matches the Rust-side errors field-for-field

Both sides reject the same input for the same reasons with the same field paths.

Rust enums generate TypeScript Zod unions automatically. Unit variants become z.literal(), and tuple or struct variants become z.object() branches:

#[derive(ZodSchema, ZodTs, serde::Deserialize)]
enum Role {
Admin,
Member,
Guest,
}

Generates:

export const RoleSchema = z.union([
z.literal("Admin"),
z.literal("Member"),
z.literal("Guest"),
]);

Serde rename attributes are respected — if your Rust enum uses #[serde(rename_all = "snake_case")], the generated literals match. See Enum Codegen for struct and tuple variant examples.

Add the generation step to your CI pipeline so generated schemas never go stale:

.github/workflows/ci.yml
- name: Generate TypeScript schemas
run: |
cargo install zod-rs-ts
zod-rs-ts src/models/ -o frontend/src/schemas/
cd frontend && npx tsc --noEmit # type-check the generated code

Or add it to a build.rs or a Makefile target so it runs before every frontend build:

.PHONY: schemas
schemas:
zod-rs-ts src/models.rs -o frontend/src/schemas/
.PHONY: frontend
frontend: schemas
cd frontend && npm run build

If you commit the generated files, your CI can also verify they are up to date:

Terminal window
zod-rs-ts src/models.rs -o frontend/src/schemas/
git diff --exit-code frontend/src/schemas/ || (echo "Generated schemas are stale" && exit 1)

Use single-source validation when:

  • Your Rust API and TypeScript frontend validate the same request or response payloads.
  • You have shared types that both sides need to agree on (user profiles, orders, config).
  • You want validation errors to be consistent between client and server.
  • Your team is tired of manually keeping two schema files in sync.

Use separate schemas when:

  • The frontend and backend validate fundamentally different shapes (e.g., the frontend form has confirm-password but the API does not).
  • You do not use Zod on the TypeScript side.
  • Your validation rules are trivial enough that drift is not a real risk.