Codegen and test data
Generate TypeScript Zod or Valibot schemas from vld schemas, so the frontend validates exactly what the server does.

vld-ts reads the JSON Schema of a vld schema (or a vld::schema! type) and prints a Zod 3 or Valibot 1 expression that accepts the same values. It has no runtime dependency besides vld (with openapi) and serde_json; run it from a build script, a test or a small binary and write the output to your frontend.

Install

Cargo.toml
[dependencies]
vld = { version = "0.5", features = ["openapi"] }
vld-ts = "0.5"

No optional features. MSRV: 1.85 (see MSRV).

Example

use vld_ts::{impl_to_valibot, impl_to_zod, to_zod, ToValibot, ToZod};

vld::schema! {
    pub struct Address {
        pub city: String => vld::string().min(1),
    }
}

vld::schema! {
    pub struct User {
        pub name: String => vld::string().min(2).max(50),
        pub address: Address => vld::nested!(Address),
    }
}

impl_to_zod!(Address);
impl_to_zod!(User, "UserBody"); // custom export name
impl_to_valibot!(User);

// A single schema value
assert_eq!(to_zod(&vld::string().email()), "z.string().email()");

// A module with named exports; the import line is yours.
let module = format!(
    "import {{ z }} from \"zod\";\n\n{}\n{}",
    Address::to_zod_export(),
    User::to_zod_export(),
);
std::fs::write("frontend/src/schemas.ts", module).unwrap();

let valibot = User::to_valibot(); // "v.object({...})"

Generated schemas.ts:

schemas.ts
import { z } from "zod";

export const AddressSchema = z.object({
  city: z.string().min(1)
});
export type Address = z.infer<typeof AddressSchema>;

export const UserBodySchema = z.object({
  name: z.string().min(2).max(50),
  address: z.lazy(() => AddressSchema)
});
export type UserBody = z.infer<typeof UserBodySchema>;

Valibot exports use v.InferOutput<typeof …> and pipes (v.pipe(v.string(), v.minLength(2), v.maxLength(50))).

API

ItemDescription
to_zod(&schema) / to_valibot(&schema)Expression for any vld::openapi::JsonSchema value
to_openapi(&schema)The plain JSON Schema (schema.json_schema())
openapi_refs(&Value)All $ref paths in a schema
impl_to_zod!(T) / impl_to_zod!(T, "Name")ToZod for a schema! / derive type
impl_to_valibot!(T) / impl_to_valibot!(T, "Name")ToValibot for a type
impl_to_openapi!(T)ToOpenApi (T::to_openapi()) and ToRefs (T::to_refs())
ToZod::{to_zod, zod_name, to_zod_export}Expression, export name, export const …Schema + export type
ToValibot::{to_valibot, valibot_name, to_valibot_export}Same for Valibot
zod_export(name, expr) / valibot_export(name, expr)Render a named export from any expression

What is generated

JSON SchemaZodValibot
string / number / integer / boolean / nullz.string(), z.number(), z.number().int(), …v.string(), v.pipe(v.number(), v.integer()), …
object (fields in schema order), additionalProperties: falsez.object({…}), .strict()v.object, v.strictObject
anyOf / oneOf (with null → nullable)z.union([…]), .nullable()v.union, v.nullable
tagged union: every option an object with a distinct string literal in a required tag (discriminated_union, #[derive(Validate)] on a tagged enum)z.discriminatedUnion("tag", […])v.variant("tag", […])
allOfz.intersection(…)v.intersect([…])
fixed-length array (minItems == maxItems, .len(n))z.array(x).length(n)v.pipe(v.array(x), v.length(n))
tuples (prefixItems)z.tuple([…])v.strictTuple / v.tupleWithRest
enum / constz.literal / z.unionv.literal / v.union
default.nullish().transform((v) => v ?? d)v.nullish(x, d)
description.describe("…")v.description("…")
$refz.lazy(() => NameSchema)v.lazy(() => NameSchema)
formats Zod/Valibot lack (slug, ulid, cron, credit card, …)regex or .refine(…)v.regex / v.check

Dates (date, date-time, time) use vld's exact ISO grammar as regexes rather than .datetime(), so both sides accept the same strings.

Gotchas

  • 0.5 change: with_default now emits x.nullish().transform((v) => v ?? d) / v.nullish(x, d), because vld accepts null for a defaulted field. Regenerate client schemas after upgrading.
  • Field order: required fields come out in declaration order (taken from required). Optional fields keep their place in the JSON map, which serde_json sorts unless its preserve_order feature is on, so turn it on to get the full declared order.
  • Zod output targets the Zod 3 API (z.string().email(), .ip({ version })).
  • $refs point at <TypeName>Schema, so export every nested type too, under its type name. Rename (impl_to_zod!(T, "Name")) only top-level types.
  • email uses .email() / v.email(), which require a dot in the domain (vld accepts a@b). url_strict's public-host check and phone_e164_strict's numbering-plan check are shape-only on the client.
  • Rust regex features JS cannot express (\p{…}, scoped flags, class set operations, …) are not checked: the output contains a /* pattern not portable to JS, not checked */ note instead of a wrong regex.
  • Refinements are attached last (a Zod refinement returns ZodEffects, which has no .min()).
  • The crate's tests can run the generated code under node: set VLD_TS_NODE_DIR to a directory whose node_modules has zod@3 and valibot@1 (see Contributing).