Codegen and test data
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
| Item | Description |
|---|---|
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 Schema | Zod | Valibot |
|---|---|---|
string / number / integer / boolean / null | z.string(), z.number(), z.number().int(), … | v.string(), v.pipe(v.number(), v.integer()), … |
object (fields in schema order), additionalProperties: false | z.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", […]) |
allOf | z.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 / const | z.literal / z.union | v.literal / v.union |
default | .nullish().transform((v) => v ?? d) | v.nullish(x, d) |
description | .describe("…") | v.description("…") |
$ref | z.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_defaultnow emitsx.nullish().transform((v) => v ?? d)/v.nullish(x, d), because vld acceptsnullfor 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 itspreserve_orderfeature 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.emailuses.email()/v.email(), which require a dot in the domain (vld acceptsa@b).url_strict's public-host check andphone_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_DIRto a directory whosenode_moduleshaszod@3andvalibot@1(see Contributing).