<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: JSON to TS</title>
    <description>The latest articles on DEV Community by JSON to TS (@jsontots).</description>
    <link>https://dev.to/jsontots</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3923005%2F16d30c2e-9b25-4219-87c4-1e5ef3d15b82.png</url>
      <title>DEV Community: JSON to TS</title>
      <link>https://dev.to/jsontots</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jsontots"/>
    <language>en</language>
    <item>
      <title>How to convert a JSON sample to a Valibot schema (and the 3 ways the algorithm diverges from Zod)</title>
      <dc:creator>JSON to TS</dc:creator>
      <pubDate>Sun, 10 May 2026 08:41:37 +0000</pubDate>
      <link>https://dev.to/jsontots/how-to-convert-a-json-sample-to-a-valibot-schema-and-the-3-ways-the-algorithm-diverges-from-zod-35no</link>
      <guid>https://dev.to/jsontots/how-to-convert-a-json-sample-to-a-valibot-schema-and-the-3-ways-the-algorithm-diverges-from-zod-35no</guid>
      <description>&lt;p&gt;When you sit down to build runtime validation for an API boundary in TypeScript, the first half-hour goes into picking a library. Zod is the default. Valibot is the 2026 upstart that you start hearing about once your bundle size gets audited — same expressive surface, but pipe-based composition and per-primitive tree-shaking instead of a monolithic chainable class.&lt;/p&gt;

&lt;p&gt;Both libraries answer the same question: &lt;em&gt;given an unknown JSON value at runtime, prove its shape.&lt;/em&gt; And both expect you to write the schema by hand. That's the part nobody likes. So I wrote a converter — paste a JSON sample, get back a Valibot schema you can tighten by hand. The tool is here, free, no signup, runs entirely in the browser: &lt;strong&gt;&lt;a href="https://json-to-ts-app.netlify.app/" rel="noopener noreferrer"&gt;json-to-ts-app.netlify.app&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Internally the Valibot emitter is a sister function to the Zod emitter I &lt;a href="https://dev.to/jsontots/how-to-convert-a-json-sample-to-a-zod-schema-and-the-4-algorithm-choices-behind-a-working-673"&gt;wrote up last week&lt;/a&gt;. Same shape walk, same naming/uniquify logic, same children-first ordering. But three things have to change once you switch validators — and they're not the things you'd guess from skimming the Valibot README. This post is about those three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape walk (recap)
&lt;/h2&gt;

&lt;p&gt;The walk is unchanged. For each node in the JSON sample:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primitive&lt;/strong&gt; → emit the leaf schema (&lt;code&gt;v.string()&lt;/code&gt;, &lt;code&gt;v.number()&lt;/code&gt;, &lt;code&gt;v.boolean()&lt;/code&gt;, &lt;code&gt;v.null()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Array&lt;/strong&gt; → recurse on every element, dedupe the resulting schemas into a union if mixed, collapse to a bare schema if uniform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object&lt;/strong&gt; → recurse on every value, give the object a &lt;code&gt;const NameSchema = v.object({...})&lt;/code&gt; binding, push it into the output list &lt;strong&gt;after&lt;/strong&gt; its children so the &lt;code&gt;const&lt;/code&gt; order is valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional&lt;/strong&gt; → if a key is missing in any sibling sample (multi-sample input), mark its value optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed types&lt;/strong&gt; → wrap them in a union.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Children-first ordering still matters even though we're no longer in Zod-land: &lt;code&gt;const&lt;/code&gt; bindings don't hoist in JavaScript, so &lt;code&gt;const Root = v.object({ user: User })&lt;/code&gt; requires &lt;code&gt;User&lt;/code&gt; to already exist by the time that line runs. Same constraint, same fix.&lt;/p&gt;

&lt;p&gt;What changes is &lt;em&gt;how&lt;/em&gt; each node emits. Three divergences from Zod, all non-obvious:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;v.optional()&lt;/code&gt; wraps the schema — it doesn't chain on it
&lt;/h2&gt;

&lt;p&gt;In Zod, you write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.optional()&lt;/code&gt; is a method on the Zod schema instance. It returns a new schema that mutates the inner one's behavior. That's why a long Zod field reads left-to-right, like a fluent builder: &lt;code&gt;z.string().min(3).max(20).optional()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Valibot has no such method. It has a top-level function called &lt;code&gt;optional&lt;/code&gt; that takes a schema and returns a new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the emitter's per-key code path can't just append &lt;code&gt;".optional()"&lt;/code&gt; to whatever &lt;code&gt;schemaStr&lt;/code&gt; it built. It has to wrap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;wrapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;optional&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v.optional(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;schemaStr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;schemaStr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds like a one-line difference — and it is — but it forces a small rethink for anyone porting a Zod codebase by hand. You don't dot a method on at the end; you wrap from the outside. Multi-modifier fields (e.g. &lt;code&gt;optional&lt;/code&gt; + &lt;code&gt;nullable&lt;/code&gt;) compose as nested calls: &lt;code&gt;v.optional(v.nullable(v.string()))&lt;/code&gt;, not &lt;code&gt;.string().nullable().optional()&lt;/code&gt;. The reading order flips inside-out.&lt;/p&gt;

&lt;p&gt;The emitter never emits a multi-modifier field today (JSON samples don't tell you "this is nullable"; they only tell you "this is null sometimes" — which becomes a union with &lt;code&gt;v.null()&lt;/code&gt;). But the wrap pattern is the right primitive for the moment that changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The header is a namespace import, not destructured
&lt;/h2&gt;

&lt;p&gt;Zod's idiomatic header is destructured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The emitter for the Zod path emits exactly that. For Valibot, the emitter switches to a namespace import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;valibot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because Valibot's main pitch is per-primitive tree-shaking. Every combinator (&lt;code&gt;v.string&lt;/code&gt;, &lt;code&gt;v.object&lt;/code&gt;, &lt;code&gt;v.optional&lt;/code&gt;, &lt;code&gt;v.union&lt;/code&gt;) is a separate exported symbol that gets dead-code-eliminated by the bundler if you don't use it. A 5-field schema using only &lt;code&gt;v.object&lt;/code&gt; and &lt;code&gt;v.string&lt;/code&gt; should ship ~600 bytes of Valibot, not the 12KB you'd get from a destructured-everything import.&lt;/p&gt;

&lt;p&gt;Bundlers can in theory tree-shake destructured imports too. In practice — and Valibot's docs are explicit about this — the namespace form is the path that lines up with the analyzer. Some bundlers (esbuild and Vite are reliable; older Webpack + Babel pipelines less so) drop dead namespace properties cleanly only when the namespace is the import shape.&lt;/p&gt;

&lt;p&gt;The single-letter &lt;code&gt;v.&lt;/code&gt; prefix keeps callsites tight either way: &lt;code&gt;v.object({ id: v.string() })&lt;/code&gt; reads about the same as &lt;code&gt;z.object({ id: z.string() })&lt;/code&gt;. The cost is paid in the import line, the win is paid back at every byte of bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;v.union([...])&lt;/code&gt; is the only composition path — there is no &lt;code&gt;.or&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Zod gives you two ways to spell a sum type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// chain form&lt;/span&gt;
&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;// function form&lt;/span&gt;
&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both work. The Zod emitter picks the function form because it reads better at arity &amp;gt; 2 and matches the docs.&lt;/p&gt;

&lt;p&gt;Valibot only has the function form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;.or()&lt;/code&gt; method to chain. There is no &lt;code&gt;.and()&lt;/code&gt; either — &lt;code&gt;v.intersect([...])&lt;/code&gt; is its analogue. The whole API is composition-by-function-call, never method-chaining. This is part of the same design that drove decision #1: a Valibot schema is a value, not an object with methods, so all combinators are top-level functions.&lt;/p&gt;

&lt;p&gt;For the emitter that means there's only one branch to write. The mixed-type case collapses to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;union&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v.unknown()&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;union&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;union&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v.union([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;union&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;])&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The &lt;code&gt;v.unknown()&lt;/code&gt; for empty arrays is a deliberate echo of the Zod path: a literal &lt;code&gt;v.never()&lt;/code&gt; would reject any element, which is wrong for a sample that just happened to be empty. &lt;code&gt;v.unknown()&lt;/code&gt; matches the TS-side &lt;code&gt;unknown[]&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The single-type-collapse is the same trick the Zod emitter uses — a 1-arg union like &lt;code&gt;v.union([v.string()])&lt;/code&gt; is degenerate and the bare schema reads identically. Worth dropping.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real example: a Stripe webhook → Valibot
&lt;/h2&gt;

&lt;p&gt;Paste a Stripe webhook payload into the &lt;a href="https://json-to-ts-app.netlify.app/stripe-webhook-to-valibot/" rel="noopener noreferrer"&gt;Stripe webhook → Valibot landing page&lt;/a&gt; and you get something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;valibot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DataObjectSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;null&lt;/span&gt;&lt;span class="p"&gt;()])),&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DataSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataObjectSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RootSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;api_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three divergences show up in this output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The header is &lt;code&gt;import * as v&lt;/code&gt; (decision #2).&lt;/li&gt;
&lt;li&gt;The optional-and-nullable &lt;code&gt;customer&lt;/code&gt; field wraps from the outside: &lt;code&gt;v.optional(v.union([v.string(), v.null()]))&lt;/code&gt; (decision #1 + decision #3 stacked).&lt;/li&gt;
&lt;li&gt;Children come first: &lt;code&gt;DataObjectSchema&lt;/code&gt; is declared before &lt;code&gt;DataSchema&lt;/code&gt;, which is declared before &lt;code&gt;RootSchema&lt;/code&gt;. Try to flip the order and the bundler / TS compiler complains about referencing a &lt;code&gt;const&lt;/code&gt; before its declaration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same machinery handles an &lt;a href="https://json-to-ts-app.netlify.app/aws-lambda-event-to-valibot/" rel="noopener noreferrer"&gt;AWS Lambda event → Valibot conversion&lt;/a&gt;, a webhook from any other provider, or any pasted JSON sample. The output is meant to be a starting point — you'll usually tighten &lt;code&gt;v.string()&lt;/code&gt; into &lt;code&gt;v.pipe(v.string(), v.email())&lt;/code&gt; for the email field, narrow &lt;code&gt;v.string()&lt;/code&gt; to &lt;code&gt;v.literal("payment_intent.succeeded")&lt;/code&gt; for known event types, and so on. But you don't write the 80-line &lt;code&gt;v.object&lt;/code&gt; shell by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why convert to Valibot specifically
&lt;/h2&gt;

&lt;p&gt;Two reasons people pick Valibot over Zod in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bundle size.&lt;/strong&gt; A medium-sized validation surface (say, 30 schemas across 8 endpoints) drops from ~14KB Zod-minified to ~2-3KB Valibot-minified, because every combinator you don't import gets dropped. For a public-facing landing page or a mobile-first app, that's the reason you switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipe-based refinement.&lt;/strong&gt; Rather than chaining methods on a string schema, you compose with &lt;code&gt;v.pipe(v.string(), v.email(), v.minLength(5))&lt;/code&gt;. The schema is built outside-in instead of inside-method-by-method. It reads more like a function pipeline, less like a builder DSL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If neither of those applies — and you're already deep in a Zod codebase — staying on Zod is fine. The converter has both modes; flip the toggle at the top of the &lt;a href="https://json-to-ts-app.netlify.app/" rel="noopener noreferrer"&gt;tool&lt;/a&gt; and the same JSON sample comes out as either schema family. The point of the converter isn't to take a side; it's to skip the boring part.&lt;/p&gt;

&lt;p&gt;The three divergences in this post are the only places where the algorithm has to actually change. The other 90% of the work — naming, ordering, deduping, multi-sample merging — is the same shape walk in both modes. Which is, in retrospect, why a "second emitter" was a Friday-afternoon ship rather than a week-long project.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>validation</category>
      <category>valibot</category>
    </item>
    <item>
      <title>How to convert a JSON sample to a Zod schema (and the 4 algorithm choices behind a working converter)</title>
      <dc:creator>JSON to TS</dc:creator>
      <pubDate>Sun, 10 May 2026 08:08:54 +0000</pubDate>
      <link>https://dev.to/jsontots/how-to-convert-a-json-sample-to-a-zod-schema-and-the-4-algorithm-choices-behind-a-working-673</link>
      <guid>https://dev.to/jsontots/how-to-convert-a-json-sample-to-a-zod-schema-and-the-4-algorithm-choices-behind-a-working-673</guid>
      <description>&lt;p&gt;A Stripe signature header proves the bytes came from Stripe. It does &lt;em&gt;not&lt;/em&gt; prove the bytes match the shape your handler expects. The same trap exists for every JSON crossing a runtime boundary: webhooks, queue payloads, third-party API responses, AI tool calls. TypeScript types are a compile-time annotation; the actual JSON drifts over time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zod.dev" rel="noopener noreferrer"&gt;Zod&lt;/a&gt; closes that gap by validating shape at runtime. The friction is hand-writing the schema. So I built a small in-browser tool that does it for you — paste a JSON sample, get a runnable &lt;code&gt;z.object&lt;/code&gt;. This post is about the four non-obvious algorithm choices that came out of that build.&lt;/p&gt;

&lt;p&gt;The tool is here, free, no signup, pure client-side: &lt;strong&gt;&lt;a href="https://json-to-ts-app.netlify.app/" rel="noopener noreferrer"&gt;json-to-ts-app.netlify.app&lt;/a&gt;&lt;/strong&gt; (toggle the output mode to &lt;code&gt;zod&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape walk
&lt;/h2&gt;

&lt;p&gt;Conceptually a JSON → Zod converter is a tree walk: visit every node, emit a Zod expression. Primitives map to &lt;code&gt;z.string()&lt;/code&gt; / &lt;code&gt;z.number()&lt;/code&gt; / &lt;code&gt;z.boolean()&lt;/code&gt; / &lt;code&gt;z.null()&lt;/code&gt;. Objects become &lt;code&gt;z.object({...})&lt;/code&gt;. Arrays become &lt;code&gt;z.array(...)&lt;/code&gt;. Easy.&lt;/p&gt;

&lt;p&gt;The trouble starts the moment you want output that compiles, that's readable, and that doesn't surprise anyone six months from now. Here are the four spots where the obvious choice is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Children-first const ordering
&lt;/h2&gt;

&lt;p&gt;TypeScript interfaces hoist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// declared after Root, still works&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zod schemas don't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RootSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;   &lt;span class="c1"&gt;//  UserSchema is undefined here&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;const&lt;/code&gt; doesn't hoist. So the emission order has to be &lt;em&gt;children before parents&lt;/em&gt;. The TS-side walk reserves the parent's slot in the output array first, then recurses. The Zod-side walk has to do the opposite: recurse first, then push the parent's slot. Same algorithm, inverted slot ordering — easy to get wrong if you copy the TS path verbatim.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateZodObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Allocate the name eagerly so the root claims its name first.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schemaName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uniquify&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Schema&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;childSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;valuesToZod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// recurse FIRST&lt;/span&gt;
    &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;quoteKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;childSchema&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;optional&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.optional()&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Push LATE — after children are pushed.&lt;/span&gt;
  &lt;span class="nx"&gt;schemas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;schemaName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`const &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;schemaName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = z.object({\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n});\n`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;schemaName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Mixed-type arrays → &lt;code&gt;z.union&lt;/code&gt;, not chained &lt;code&gt;.or()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Both work. Both produce identical runtime behaviour. But &lt;code&gt;z.union([z.string(), z.number(), z.null()])&lt;/code&gt; reads better than &lt;code&gt;z.string().or(z.number()).or(z.null())&lt;/code&gt; once arity goes past two, and &lt;code&gt;z.union&lt;/code&gt; is what the official Zod docs and the bulk of real codebases use. Generated code that &lt;em&gt;looks&lt;/em&gt; like the docs is generated code people trust.&lt;/p&gt;

&lt;p&gt;The single-element case collapses to the bare schema (no degenerate one-arg union). Empty arrays specifically become &lt;code&gt;z.array(z.unknown())&lt;/code&gt; — &lt;code&gt;z.never()&lt;/code&gt; would be wrong, because it would reject every non-empty array later.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;.optional()&lt;/code&gt;, not &lt;code&gt;.nullish()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;These are semantically different and people mix them up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operator&lt;/th&gt;
&lt;th&gt;Allows missing key&lt;/th&gt;
&lt;th&gt;Allows &lt;code&gt;null&lt;/code&gt; value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.optional()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.nullable()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.nullish()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you merge several samples of the same shape and the field is &lt;em&gt;missing in some samples&lt;/em&gt;, that's &lt;code&gt;optional&lt;/code&gt;. If the field is present-but-&lt;code&gt;null&lt;/code&gt; in some samples, that's &lt;code&gt;nullable&lt;/code&gt;. They are different signals and the converter keeps them separate: &lt;code&gt;optional&lt;/code&gt; is set when an array of similar objects has the key missing on at least one item; &lt;code&gt;null&lt;/code&gt; values surface as a &lt;code&gt;z.null()&lt;/code&gt; member of the value's union.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Each non-leaf object becomes its own named const
&lt;/h2&gt;

&lt;p&gt;The "easy" implementation inlines everything into one giant &lt;code&gt;z.object&lt;/code&gt;. A Stripe webhook output looks like an 80-line nested expression nobody will diff or reuse. The converter instead names each non-leaf object — &lt;code&gt;RootSchema&lt;/code&gt;, &lt;code&gt;DataSchema&lt;/code&gt;, &lt;code&gt;ObjectSchema&lt;/code&gt;, etc. — and uniquifies on collision (&lt;code&gt;UserSchema&lt;/code&gt;, &lt;code&gt;UserSchema2&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Same code that handles TypeScript interface naming, just with a &lt;code&gt;Schema&lt;/code&gt; suffix appended.&lt;/p&gt;




&lt;h2&gt;
  
  
  A real example: Stripe webhook
&lt;/h2&gt;

&lt;p&gt;Paste a &lt;code&gt;payment_intent.succeeded&lt;/code&gt; event into &lt;a href="https://json-to-ts-app.netlify.app/stripe-webhook-to-zod/" rel="noopener noreferrer"&gt;json-to-ts-app.netlify.app/stripe-webhook-to-zod/&lt;/a&gt; and you get four named schemas in dependency order: the inner &lt;code&gt;data.object&lt;/code&gt; first, then &lt;code&gt;DataSchema&lt;/code&gt; referring to it, then &lt;code&gt;RootSchema&lt;/code&gt; referring to that. Drop the output into your handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RootSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;//    ^? type-narrowed; missing/extra fields throw a single named error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiple event types? Generate one schema per type, then combine them with &lt;code&gt;z.discriminatedUnion("type", [...])&lt;/code&gt; and your handler narrows on &lt;code&gt;event.type&lt;/code&gt; cleanly.&lt;/p&gt;

&lt;p&gt;The same playbook works for any JSON-over-HTTP boundary — there are landing pages with worked examples for &lt;a href="https://json-to-ts-app.netlify.app/aws-lambda-event-to-zod/" rel="noopener noreferrer"&gt;AWS Lambda events&lt;/a&gt;, GitHub API responses, OpenAI chat completions, and JSON:API documents under the same tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a converter at all?
&lt;/h2&gt;

&lt;p&gt;Because hand-writing Zod schemas for nested API payloads is the most boring kind of code there is, and the time you spent writing it is time you didn't spend deciding &lt;em&gt;what to do&lt;/em&gt; when validation fails. Generate it, edit it (rename the constants, tighten the types where you have stronger constraints than "string"), commit it.&lt;/p&gt;

&lt;p&gt;The tool runs entirely in your browser — your JSON never leaves the tab. Source is MIT on &lt;a href="https://github.com/SolvoHQ/json-to-ts" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; if you want to see the full algorithm.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're on the fence about adding runtime validation: the cost is one parse call at the boundary, sub-millisecond on typical API payloads. The win is that schema drift becomes a single named error in one place, not a &lt;code&gt;Cannot read property 'x' of undefined&lt;/code&gt; thirty stack frames deep.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>zod</category>
    </item>
  </channel>
</rss>
