Skip to main content

vld_ts/
lib.rs

1//! # vld-ts — Generate TypeScript Zod schemas from JSON Schema
2//!
3//! Converts JSON Schemas (as produced by `vld`'s `json_schema()` methods)
4//! into TypeScript [Zod](https://zod.dev/) schema code.
5//!
6//! # Example
7//!
8//! ```
9//! use vld_ts::json_schema_to_zod;
10//!
11//! let schema = serde_json::json!({
12//!     "type": "object",
13//!     "required": ["name", "email"],
14//!     "properties": {
15//!         "name": {"type": "string", "minLength": 2, "maxLength": 50},
16//!         "email": {"type": "string", "format": "email"},
17//!         "age": {"type": "integer", "minimum": 0}
18//!     }
19//! });
20//!
21//! let ts = json_schema_to_zod(&schema);
22//! assert!(ts.contains("z.object("));
23//! assert!(ts.contains("z.string()"));
24//! ```
25
26use serde_json::Value;
27
28/// Convert a JSON Schema to TypeScript Zod schema code.
29///
30/// Produces a string like `z.object({ name: z.string().min(2), ... })`.
31///
32/// Supports types: `string`, `number`, `integer`, `boolean`, `array`,
33/// `object`, `null`, and combinators (`oneOf`, `allOf`, `anyOf`).
34pub fn json_schema_to_zod(schema: &Value) -> String {
35    convert_schema(schema)
36}
37
38/// Generate named Zod schemas for multiple types.
39///
40/// # Example
41///
42/// ```
43/// use vld_ts::generate_zod_file;
44///
45/// let schemas = vec![
46///     ("User", serde_json::json!({"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}})),
47///     ("Email", serde_json::json!({"type": "string", "format": "email"})),
48/// ];
49///
50/// let ts = generate_zod_file(&schemas);
51/// assert!(ts.contains("export const UserSchema"));
52/// assert!(ts.contains("export const EmailSchema"));
53/// ```
54pub fn generate_zod_file(schemas: &[(&str, Value)]) -> String {
55    let mut lines = vec![
56        "// Auto-generated by vld-ts — do not edit manually".to_string(),
57        "import { z } from \"zod\";\n".to_string(),
58    ];
59
60    for (name, schema) in schemas {
61        let zod = convert_schema(schema);
62        lines.push(format!("export const {}Schema = {};", name, zod));
63        lines.push(format!(
64            "export type {} = z.infer<typeof {}Schema>;\n",
65            name, name
66        ));
67    }
68
69    lines.join("\n")
70}
71
72fn convert_schema(schema: &Value) -> String {
73    // Handle combinators first
74    if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) {
75        // Check if it's a nullable pattern: [inner, {"type": "null"}]
76        if one_of.len() == 2 {
77            let is_null_0 = one_of[0].get("type").and_then(|t| t.as_str()) == Some("null");
78            let is_null_1 = one_of[1].get("type").and_then(|t| t.as_str()) == Some("null");
79            if is_null_1 && !is_null_0 {
80                return format!("{}.nullable()", convert_schema(&one_of[0]));
81            }
82            if is_null_0 && !is_null_1 {
83                return format!("{}.nullable()", convert_schema(&one_of[1]));
84            }
85        }
86        let variants: Vec<String> = one_of.iter().map(convert_schema).collect();
87        return format!("z.union([{}])", variants.join(", "));
88    }
89
90    if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
91        let parts: Vec<String> = all_of.iter().map(convert_schema).collect();
92        if parts.len() == 1 {
93            return parts[0].clone();
94        }
95        let mut result = parts[0].clone();
96        for p in &parts[1..] {
97            result = format!("z.intersection({}, {})", result, p);
98        }
99        return result;
100    }
101
102    if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) {
103        let variants: Vec<String> = any_of.iter().map(convert_schema).collect();
104        return format!("z.union([{}])", variants.join(", "));
105    }
106
107    // Handle enum
108    if let Some(enum_vals) = schema.get("enum").and_then(|v| v.as_array()) {
109        let literals: Vec<String> = enum_vals
110            .iter()
111            .map(|v| match v {
112                Value::String(s) => format!("z.literal(\"{}\")", s),
113                Value::Number(n) => format!("z.literal({})", n),
114                Value::Bool(b) => format!("z.literal({})", b),
115                Value::Null => "z.null()".to_string(),
116                _ => "z.unknown()".to_string(),
117            })
118            .collect();
119        if literals.len() == 1 {
120            return literals[0].clone();
121        }
122        return format!("z.union([{}])", literals.join(", "));
123    }
124
125    // Get the type
126    let type_str = schema.get("type").and_then(|t| t.as_str()).unwrap_or("");
127
128    match type_str {
129        "string" => convert_string(schema),
130        "number" => convert_number(schema),
131        "integer" => convert_integer(schema),
132        "boolean" => "z.boolean()".to_string(),
133        "null" => "z.null()".to_string(),
134        "array" => convert_array(schema),
135        "object" => convert_object(schema),
136        _ => "z.unknown()".to_string(),
137    }
138}
139
140fn convert_string(schema: &Value) -> String {
141    let mut s = "z.string()".to_string();
142
143    if let Some(min) = schema.get("minLength").and_then(|v| v.as_u64()) {
144        s.push_str(&format!(".min({})", min));
145    }
146    if let Some(max) = schema.get("maxLength").and_then(|v| v.as_u64()) {
147        s.push_str(&format!(".max({})", max));
148    }
149    if let Some(format) = schema.get("format").and_then(|v| v.as_str()) {
150        match format {
151            "email" => s.push_str(".email()"),
152            "uri" | "url" => s.push_str(".url()"),
153            "uuid" => s.push_str(".uuid()"),
154            "ipv4" => s.push_str(".ip({ version: \"v4\" })"),
155            "ipv6" => s.push_str(".ip({ version: \"v6\" })"),
156            "date" => s.push_str(".date()"),
157            "date-time" => s.push_str(".datetime()"),
158            "time" => s.push_str(".time()"),
159            _ => { /* skip unknown formats */ }
160        }
161    }
162    if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
163        s.push_str(&format!(".regex(/{}/)", pattern));
164    }
165
166    add_description(&mut s, schema);
167    s
168}
169
170fn convert_number(schema: &Value) -> String {
171    let mut s = "z.number()".to_string();
172    add_numeric_constraints(&mut s, schema);
173    add_description(&mut s, schema);
174    s
175}
176
177fn convert_integer(schema: &Value) -> String {
178    let mut s = "z.number().int()".to_string();
179    add_numeric_constraints(&mut s, schema);
180    add_description(&mut s, schema);
181    s
182}
183
184fn add_numeric_constraints(s: &mut String, schema: &Value) {
185    if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
186        s.push_str(&format!(".min({})", format_number(min)));
187    }
188    if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
189        s.push_str(&format!(".max({})", format_number(max)));
190    }
191    if let Some(gt) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
192        s.push_str(&format!(".gt({})", format_number(gt)));
193    }
194    if let Some(lt) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
195        s.push_str(&format!(".lt({})", format_number(lt)));
196    }
197    if let Some(mul) = schema.get("multipleOf").and_then(|v| v.as_f64()) {
198        s.push_str(&format!(".multipleOf({})", format_number(mul)));
199    }
200}
201
202fn convert_array(schema: &Value) -> String {
203    let items = schema
204        .get("items")
205        .map(convert_schema)
206        .unwrap_or_else(|| "z.unknown()".to_string());
207
208    let mut s = format!("z.array({})", items);
209
210    if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
211        s.push_str(&format!(".min({})", min));
212    }
213    if let Some(max) = schema.get("maxItems").and_then(|v| v.as_u64()) {
214        s.push_str(&format!(".max({})", max));
215    }
216    if schema.get("uniqueItems").and_then(|v| v.as_bool()) == Some(true) {
217        s.push_str(" /* uniqueItems */");
218    }
219
220    add_description(&mut s, schema);
221    s
222}
223
224fn convert_object(schema: &Value) -> String {
225    let required: Vec<&str> = schema
226        .get("required")
227        .and_then(|v| v.as_array())
228        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
229        .unwrap_or_default();
230
231    let props = schema.get("properties").and_then(|v| v.as_object());
232
233    if let Some(props) = props {
234        let mut fields: Vec<String> = Vec::new();
235        for (key, prop_schema) in props {
236            let mut zod = convert_schema(prop_schema);
237            if !required.contains(&key.as_str()) {
238                zod.push_str(".optional()");
239            }
240            fields.push(format!("  {}: {}", key, zod));
241        }
242
243        let mut s = format!("z.object({{\n{}\n}})", fields.join(",\n"));
244
245        // additionalProperties
246        if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
247            s.push_str(".strict()");
248        }
249
250        add_description(&mut s, schema);
251        s
252    } else {
253        // Record-like schema
254        if let Some(additional) = schema.get("additionalProperties") {
255            if additional.is_object() {
256                let value_schema = convert_schema(additional);
257                return format!("z.record(z.string(), {})", value_schema);
258            }
259        }
260
261        let mut s = "z.object({})".to_string();
262        if schema.get("additionalProperties") != Some(&Value::Bool(false)) {
263            s.push_str(".passthrough()");
264        }
265        s
266    }
267}
268
269fn add_description(s: &mut String, schema: &Value) {
270    if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
271        s.push_str(&format!(".describe(\"{}\")", desc.replace('"', "\\\"")));
272    }
273}
274
275fn format_number(n: f64) -> String {
276    if n == n.floor() && n.abs() < 1e15 {
277        format!("{}", n as i64)
278    } else {
279        format!("{}", n)
280    }
281}