A Stripe signature header proves the bytes came from Stripe. It does not 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.
Zod 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 z.object. This post is about the four non-obvious algorithm choices that came out of that build.
The tool is here, free, no signup, pure client-side: json-to-ts-app.netlify.app (toggle the output mode to zod).
The shape walk
Conceptually a JSON → Zod converter is a tree walk: visit every node, emit a Zod expression. Primitives map to z.string() / z.number() / z.boolean() / z.null(). Objects become z.object({...}). Arrays become z.array(...). Easy.
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.
1. Children-first const ordering
TypeScript interfaces hoist:
interface Root { user: User }
interface User { id: string } // declared after Root, still works
Zod schemas don't:
const RootSchema = z.object({ user: UserSchema }); // UserSchema is undefined here
const UserSchema = z.object({ id: z.string() });
const doesn't hoist. So the emission order has to be children before parents. 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.
function generateZodObject(merged, hint) {
// Allocate the name eagerly so the root claims its name first.
const schemaName = uniquify(...) + "Schema";
const lines = [];
for (const key of Object.keys(merged)) {
const childSchema = valuesToZod(merged[key].values, key); // recurse FIRST
lines.push(` ${quoteKey(key)}: ${childSchema}${merged[key].optional ? ".optional()" : ""},`);
}
// Push LATE — after children are pushed.
schemas.push({ name: schemaName, body: `const ${schemaName} = z.object({\n${lines.join("\n")}\n});\n` });
return schemaName;
}
2. Mixed-type arrays → z.union, not chained .or()
Both work. Both produce identical runtime behaviour. But z.union([z.string(), z.number(), z.null()]) reads better than z.string().or(z.number()).or(z.null()) once arity goes past two, and z.union is what the official Zod docs and the bulk of real codebases use. Generated code that looks like the docs is generated code people trust.
The single-element case collapses to the bare schema (no degenerate one-arg union). Empty arrays specifically become z.array(z.unknown()) — z.never() would be wrong, because it would reject every non-empty array later.
3. .optional(), not .nullish()
These are semantically different and people mix them up.
| Operator | Allows missing key | Allows null value |
|---|---|---|
.optional() |
yes | no |
.nullable() |
no | yes |
.nullish() |
yes | yes |
If you merge several samples of the same shape and the field is missing in some samples, that's optional. If the field is present-but-null in some samples, that's nullable. They are different signals and the converter keeps them separate: optional is set when an array of similar objects has the key missing on at least one item; null values surface as a z.null() member of the value's union.
4. Each non-leaf object becomes its own named const
The "easy" implementation inlines everything into one giant z.object. A Stripe webhook output looks like an 80-line nested expression nobody will diff or reuse. The converter instead names each non-leaf object — RootSchema, DataSchema, ObjectSchema, etc. — and uniquifies on collision (UserSchema, UserSchema2).
Same code that handles TypeScript interface naming, just with a Schema suffix appended.
A real example: Stripe webhook
Paste a payment_intent.succeeded event into json-to-ts-app.netlify.app/stripe-webhook-to-zod/ and you get four named schemas in dependency order: the inner data.object first, then DataSchema referring to it, then RootSchema referring to that. Drop the output into your handler:
import { z } from "zod";
const event = RootSchema.parse(JSON.parse(req.body));
// ^? type-narrowed; missing/extra fields throw a single named error
Multiple event types? Generate one schema per type, then combine them with z.discriminatedUnion("type", [...]) and your handler narrows on event.type cleanly.
The same playbook works for any JSON-over-HTTP boundary — there are landing pages with worked examples for AWS Lambda events, GitHub API responses, OpenAI chat completions, and JSON:API documents under the same tool.
Why a converter at all?
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 what to do when validation fails. Generate it, edit it (rename the constants, tighten the types where you have stronger constraints than "string"), commit it.
The tool runs entirely in your browser — your JSON never leaves the tab. Source is MIT on GitHub if you want to see the full algorithm.
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 Cannot read property 'x' of undefined thirty stack frames deep.
Top comments (0)