JSON to Zod Schema Generator
Turn a JSON response into a Zod schema you can put at a fetch boundary, together with the z.infer type so the static type comes from the schema instead of being declared twice. Zod tells a missing key apart from a null value, which is why the sample count matters here. Paste a few responses and each key comes out as .optional(), .nullable(), or .nullish() based on what actually arrived.
Worked example
{"id":1,"email":"[email protected]","nickname":"ada","tags":["beta"]}
{"id":2,"email":"[email protected]","nickname":null,"tags":[]}
{"id":3,"email":"[email protected]","tags":["beta","ga"]}becomes
import { z } from "zod";
export const RootSchema = z.object({
id: z.number(),
email: z.string(),
nickname: z.string().nullish(),
tags: z.array(z.string()),
});
export type Root = z.infer<typeof RootSchema>;nickname is missing from the third record and null in the second, so it is both optional and nullable, which Zod spells .nullish(). tags is empty in one record but holds strings in the others, so the element type survives the empty case. The schema states what the three samples showed, with no hand edits.
Frequently asked questions
What is the difference between .optional(), .nullable(), and .nullish() in Zod?
.optional() allows the key to be missing (the value may be undefined). .nullable() allows the value to be null while the key is still present. .nullish() allows both. They are not interchangeable. A schema with .optional() rejects an explicit null, and a schema with .nullable() rejects a missing key, which is why generating from one sample tends to produce a schema that fails on the second response.
Should I use z.object, strict, or passthrough?
Plain z.object strips keys it does not know about, which is a safe default at a boundary. .strict() rejects unknown keys outright, which is useful for config you control and hostile for a third-party API that adds fields. .passthrough() keeps unknown keys on the parsed value, which is what you want when you are forwarding a payload you only partly understand.
Why export z.infer instead of writing the type by hand?
Because two declarations of the same shape drift apart. z.infer derives the static type from the schema, so changing the schema changes the type in the same commit and TypeScript points at the call sites that now disagree. The generator writes export type X = z.infer<typeof XSchema> next to each schema, and you can switch that off if your project keeps its types elsewhere.
Where should the schema run in a fetch?
At the point the response becomes application data, normally right after response.json(). Call XSchema.parse(data) when a bad payload should throw, or XSchema.safeParse(data) when you want to handle the failure yourself. Everything downstream then holds the inferred type honestly, rather than a type assertion that nothing ever verified.