JSON to Zod Schema
Paste sample JSON, get a Zod schema plus z.infer types — optionality from multiple samples, entirely on this device.
A plain z.object() accepts unknown keys but drops them from the parsed result; .strict() rejects them and .passthrough() keeps them.
Default keeps them strings: JSON has no date type, and z.coerce.date() changes the parsed value into a Date rather than the text you sent.
Literal keys by default: an object whose keys are data (ids, locales) can be read as a record instead, which drops the key names from the schema.
Enums default off: a value repeated across your samples is often coincidence, and z.enum() would reject every value you have not seen yet.
The Zod schema appears here as you type.
npx quicktype --lang typescript-zod --top-level Root sample.jsonPress ? for shortcuts
Do it offline:$ npx quicktype --lang typescript-zod --top-level Root sample.json
How to use it
- Paste a response into the first tab, or drop a .json file on the input pane — the Zod source rebuilds as you type.
- Give each further response from that endpoint its own tab; the shapes merge before naming, so a gap surfaces as .optional().
- Set the Schema name, then choose the import spelling, the unknown-key policy, date handling and whether z.infer aliases come along.
- Read the chips under the output before copying: each marks a spot where the samples ran out of evidence.
You have payloads and no schema yet. Paste one, or several, and get Zod source back: a schema per object shape, a z.infer alias beside each, and unions assembled only from values that actually turned up. Treat the result as a starting point rather than a finished contract — it carries the structure, and you add the checks no sample could have proved. Nothing is uploaded.
Examples
{"id": 1, "tags": ["a"], "meta": {"active": true}}import * as z from "zod";
export const MetaSchema = z.object({
"active": z.boolean(),
});
export type Meta = z.infer<typeof MetaSchema>;
export const RootSchema = z.object({
"id": z.number(),
"tags": z.array(z.string()),
"meta": MetaSchema,
});
export type Root = z.infer<typeof RootSchema>;
The default view. A nested object earns its own MetaSchema and is referenced rather than inlined, the array takes the type of the element inside it, and each schema gets its z.infer alias.
{"a": 1, "b": "x"}
{"a": null, "c": true}import * as z from "zod";
export const RootSchema = z.object({
"a": z.union([z.number(), z.null()]),
"b": z.string().optional(),
"c": z.boolean().optional(),
});
export type Root = z.infer<typeof RootSchema>;
Two tabs, and the merge does the work: a held a number once and null once, so it widens into a union of those two; b and c each showed up in one tab only, so both come back optional.
[{"a":1}]import * as z from "zod";
export const RootElementSchema = z.object({
"a": z.number(),
});
export type RootElement = z.infer<typeof RootElementSchema>;
export const RootSchema = z.array(RootElementSchema);
export type Root = z.infer<typeof RootSchema>;
An edge case worth meeting early. A document whose top level is an array leaves the generator naming the element only, so RootSchema is wrapped around that element schema — otherwise the file has nothing to parse the document with.
{"at":"2026-08-11T10:00:00Z"}import * as z from "zod";
export const RootSchema = z.object({
"at": z.coerce.date(),
});
export type Root = z.infer<typeof RootSchema>;
Date handling switched on, and the value changes shape: parsing returns a Date object where the payload held a string. Left off — the default — the field stays z.string().
Every example on this page is executed against the same engine that powers the tool, on every build.
Gotchas worth knowing
An array that was always empty has no element type
Nothing says what belongs inside, so the items come out as z.unknown(): the array parses, and the compiler makes you narrow an item first. The other setting emits z.any(), which switches checking off.
z.object({}) lets any object through
A key-less object gives the generator nothing to declare, and an empty Zod object accepts keys it was not told about — then drops them from the parsed result. Setting unknown keys to reject repairs it: the schema then accepts no properties at all.
Keys arrive quoted and stay that way
Property names are emitted as string literals, so a hyphen, a leading digit or an empty key survives intact and an emoji key is written as its escape pair. Nothing is renamed, so the schema matches the wire format.
Many same-shaped keys can become z.record
An object used as a lookup table — dozens of ids, each holding one shape — reads better as z.record(z.string(), …) than as dozens of literal properties. That is the Same-shaped objects setting; it ships on Literal keys, because z.record stops checking which keys exist.
The schema name is normalized to an identifier
Whatever you type in the Schema name field must become a TypeScript declaration, so my type! is emitted as MyTypeSchema and a chip records the substitution.
Common errors
Input is larger than the 10 MB limit for this tool. For bigger payloads use the CLI: npx quicktype --lang typescript-zod --top-level Root sample.json- Across every tab, ten megabytes is the ceiling, and past it the paste is declined whole. Inference reads shape, and a few records carry the same shape as a full export — trim the excerpt, or aim the printed command at the file.
Sample 1: Unexpected '"' at line 1, column 23 — probable cause: missing comma between properties.- Each tab has to hold strict JSON by itself. The failing tab is named, the caret frame points at the character, and generation waits until that tab parses.
No constraints were inferred: nothing in a sample proves a string is an email or a number has a minimum. Add .min(), .email(), .uuid() and .regex() by hand where you need them.- This chip appears on every successful run: it is the standing limit of the method. Values show which kinds occur, never which ranges are allowed, so the rules stay yours to write.
The top level of this document is a number — quicktype names object types only, so no root schema was generated. Wrap the value in an object (for example {"data": …}) to get one.- A bare 42, or a list of plain strings, produces the import line and nothing else. Rather than invent a declaration, the run says what it found and suggests the wrapper.
Null everywhere it appeared: Root.avatar_url — z.null() accepts only null. Add input where the field has a value so a real type can be read from it.- A field that was null in every tab types as null, which rejects the first real value it meets. One response where it is populated widens the union.
FAQ
Is the generated schema ready to ship as it stands?
As a shape check, yes; as a contract, not yet. It knows the keys and kinds your samples contained and nothing about lengths, ranges, formats or permitted strings. Add those checks where they matter and the file becomes what you wanted at the boundary.
What makes a field come out optional?
Missing from at least one pasted sample — that alone. A key present in all of them is emitted required, whatever it is called. If the gaps were an accident of sampling, turn optionality inference off and every key is emitted required.
How many samples are worth pasting?
Two or three responses from one endpoint usually settle it. A single payload states that everything it happened to contain is mandatory; a handful reveals the nullable fields and the keys that come and go.
Keyboard shortcuts
- ⌘/Ctrl + Enter
- Regenerate at once, without waiting out the typing pause.
- ⌘/Ctrl + S
- Write the schema out as a .ts download, not the browser's page save.
- ⌘/Ctrl + C
- Lift the whole generated file to the clipboard while nothing is selected.
- ⌘/Ctrl + Shift + C
- Grab the full file even with a selection active elsewhere.
- t
- Flip between light and dark, unless a text field or IME has focus.
- ? or ⌘/Ctrl + /
- Bring up the shortcut list, drawn with your platform's glyphs.
- Esc
- Shut the frontmost overlay, leaving every sample tab as it was.