JSON to Zod

Generate Zod schemas from JSON, each with its z.infer type: array elements merged, optional and nullable keys marked, nothing guessed from the sample.

Input
Zod schema
import * as z from "zod"

export const Customer = z.object({
  name: z.string(),
  email: z.string(),
  phone: z.null(),
})
export type Customer = z.infer<typeof Customer>

export const LineItem = z.object({
  sku: z.string(),
  quantity: z.number(),
  price: z.number(),
  note: z.string().nullable(),
  backordered: z.boolean().optional(),
})
export type LineItem = z.infer<typeof LineItem>

export const Root = z.object({
  id: z.string(),
  customer: Customer,
  lineItems: z.array(LineItem),
  tags: z.array(z.unknown()),
})
export type Root = z.infer<typeof Root>

Zod 4 syntax that is also valid on Zod 3. z.object drops every key it does not list, so a field your sample lacked is silently removed from the data it parses.

  • At each position below, your sample only ever held null, so the schema accepts nothing else there.

    Positions: 1

    • Customer.phone
  • At each position below, every number was whole. z.number() also accepts 7.5, and .int() would require whole numbers, so add it yourself only where you know a value must be whole.

    Positions: 1

    • LineItem.quantity
  • At each position below, nothing could be inferred, so the schema does not check what is there: z.unknown() accepts any value, and z.record(z.string(), z.unknown()) any object.

    Positions: 1

    • Root.tags[]

A check that runs each time the data arrives

A TypeScript type is checked when your code compiles and is gone by the time it runs. A Zod schema is the part that stays: it runs inside your program and checks each payload as it comes in — an API response, a webhook body, a message off a queue — before your code relies on it. Paste a sample of that JSON and this page writes the schemas for you, ready to paste into a module: every object that has keys becomes a named "z.object", the elements of an array merged into one, and each schema is followed by the TypeScript type it produces.

It is the answer the JSON to TypeScript page gives for the same JSON, spelled as a validator instead of as types. Both pages read one inference, so which keys are optional, which values become a union, where null is kept apart from a missing key and what each nested object is called are decided once and only written out twice. Those rules of merging are the subject of the guide on the JSON to TypeScript page and are not told again here. This guide is about what the schema does with real data once it runs, and what it leaves for you to decide.

What passes, and what is turned away

A schema from this page accepts the sample it was written from, on Zod 3 and on Zod 4 alike, except in the one case that has a finding of its own, an infinite number on Zod 4. Beyond that sample it accepts whatever stays inside what the schema says, which works out like this for the data you will actually receive:

  • Short of the depth limit, a key written without ".optional()" is required. A payload that leaves it out is rejected, and so is one that puts a kind of value there that the schema does not name — a string where it says "z.number()", an object where it says "z.string()".
  • A key written with ".optional()" may be missing, and one written with ".nullable()" may hold null. Neither lets through any other kind of value, so an optional key that is present still has to hold what the schema names.
  • A "z.union" takes any of its members and nothing else. A "z.array" takes any number of elements, none at all included, as long as each one fits the schema written for its elements.
  • Where the sample showed nothing — the elements of an empty array, an object with no keys, a value nested past the depth limit — the schema does not check what is there: it takes any value, or any object where the sample’s object had no keys, and a finding says where.

A key the schema does not list is let through and then dropped from the result; that is the strip, and it has a section of its own. One key stands outside all of this. A key spelled "__proto__" is written as a computed key, in square brackets, because written plainly it would set the prototype of the object it sits in instead of naming a key — yet neither version of Zod returns that key in what a parse gives back, and Zod 4 does not check its value at all.

Why the schema never tightens itself

Every sample has things in common that it cannot prove: every id a whole number, every email shaped like an email, a role that only ever said admin. That is a regularity, and a regularity is not a check. A generator is tempted to write one down anyway — ".int()" on the ids, "z.email()" on the addresses, a literal on the role — and this one never does, because a sample shows what your data can hold and never what it must. A type that is tighter than your data costs a compile error on your own machine. A schema that is tighter than your data costs a rejected request in production: the first role that is not admin, the first id of 7.5, the first address the pattern did not foresee.

The checks would also move under you. Zod 4’s "z.uuid()" rejects strings shaped like a UUID that Zod 3’s ".uuid()" accepted, because it checks the variant bits, and Zod 4’s ".int()" rejects an integer outside the safe range that Zod 3’s let through — so a check guessed from today’s sample would be a different check on whichever Zod you install. Nothing the sample merely suggests is written into the schema, then. Where it matters when data arrives, the page tells you in a finding instead, and the edit is left to you.

What the schema will not say for itself

Under the schema, the page lists what it noticed and did not write in: a finding for each kind below that applies, given once with every position it holds for. A position is written the way the output names things — "Customer.phone" for a key, "Root.tags[]" for the elements of an array, the key in quotes and square brackets where it is not an identifier and for "__proto__" — so it can be found in the schema at a glance. The example that loads with the page shows every kind but the one about Infinity.

  • Only null. The sample never held anything but null at that position, so the schema says "z.null()" and turns away the first real value. Decide what the field holds when it is filled in and write that yourself — "z.string().nullable()", for instance — or paste a sample in which it has a value.
  • Infinity. A number beyond what a JavaScript number can hold, such as 1e999, becomes Infinity or -Infinity when the JSON is parsed. Zod 4’s "z.number()" rejects an infinite number and Zod 3’s accepts it, so on Zod 4 this is the one case in which a schema rejects the very sample it was made from. The question it raises is about the data rather than the schema: the digits are lost before any validator sees them, so whether that value should be a number at all is for whatever wrote it to answer.
  • Whole numbers. Every number at that position was whole, and "z.number()" also accepts 7.5. Where a value must be whole — an id, a count, a quantity — add ".int()" yourself; where it is a price that happened to be round, leave it alone. The finding is withheld from any position holding an integer outside the safe range, from "Number.MIN_SAFE_INTEGER" to "Number.MAX_SAFE_INTEGER", because Zod 4’s ".int()" rejects those, and advice that would make a schema reject its own sample is the one kind the page will not give.
  • Nothing to infer. The elements of an empty array, an object with no keys and a value nested more than 100 levels deep give the inference nothing to go on, so the schema does not check what is there: "z.unknown()" accepts any value at all, and "z.record(z.string(), z.unknown())" any object. Paste a sample in which that array has elements and that object has keys, or write that part of the schema by hand.

A finding never changes a byte of the schema. It is a sentence beside it, in the page’s language, and the edit it points to is yours to make or to skip. There is no finding about string formats, literals or enums: each would be a guess at a rule the sample cannot show.

Keys the schema does not list are dropped

"z.object" lets through an object carrying keys it does not list and leaves them out of what it returns. That is Zod’s default on both versions, and the quietest thing a schema does: a field your sample happened to lack vanishes from the data your code receives, with no error to say so. It is why the sentence under every schema on this page mentions it.

The page does not choose strict or loose for you, because each claims more than a sample can show. A strict object rejects any key it does not list — no keys but these — and no sample can prove that of the next payload. A loose object keeps extra keys, and its inferred type gains an index signature for them, so it would stop being the TypeScript page’s answer. The strip is the one behaviour that takes what the TypeScript type takes and still infers that type — a value with extra properties satisfies an interface too. To choose otherwise, change the schema by hand:

  • To reject keys the schema does not list, write "z.strictObject" where the output writes "z.object" on Zod 4, or chain ".strict()" onto the "z.object" on Zod 3.
  • To keep them, write "z.looseObject" on Zod 4, or chain ".passthrough()" on Zod 3. Zod 4 still runs both of those Zod 3 methods, and calls them legacy.

Each object in the output is a schema of its own, so the choice is made one object at a time: making the root strict changes nothing about the objects nested inside it, which is often what you want when only the outer envelope is yours to insist on.

One name for each schema and its type

Each schema is followed by its type — "export type Customer = z.infer<typeof Customer>" right after "export const Customer" — which is how zod.dev writes its own examples: one name for the value that checks the data and for the type it produces, since TypeScript keeps values and types in separate namespaces. That type is the one the JSON to TypeScript page prints for the same JSON, name for name and key for key, with the same optional keys, the same unions and null in the same places — short of the exceptions below.

The repository checks that promise rather than trusting it: a corpus of samples goes through both pages and then through the TypeScript compiler with each version of Zod, which is asked, for every name, whether the two types are identical and whether each is assignable to the other. Where it answers otherwise, the place is deep in a document. Past the depth limit, where a key holds "z.unknown()", Zod 3 infers that key as optional while the TypeScript page makes it required. And on Zod 4 the compiler gives up on the type of an array nested dozens of levels deep, reporting error TS2589 on the type’s own line; the schema above that line still runs and still accepts the sample, and only the inferred type is lost.

Both comparisons read an optional key the way TypeScript does by default. Under "exactOptionalPropertyTypes", which is off unless a project turns it on, an optional key’s "z.infer" also admits an explicit undefined, on either version of Zod, where the TypeScript page’s type does not.

Written for Zod 4, and still valid on Zod 3

The output keeps to what both major versions have — "z.object", "z.array", "z.union" over two or more members, "z.string()", "z.number()", "z.boolean()", "z.null()", "z.unknown()", "z.record(z.string(), z.unknown())", ".optional()", ".nullable()" and "z.infer" — under the import line zod.dev’s own examples open with. Nothing in it arrived in Zod 4 and nothing in it is deprecated there, so a project that has not moved off Zod 3 can paste it as it is.

The same text does not behave identically on both, though, and each difference is said in this guide where it matters. Zod 4’s "z.number()" rejects an infinite number where Zod 3’s accepts it, which is the Infinity finding. A key whose value is "z.unknown()" is optional in Zod 3’s inferred type and required in Zod 4’s — and required when the data is parsed as well, from Zod 4.4 — which this output only ever writes past the depth limit. Zod 3 checks a key spelled "__proto__" and Zod 4 does not. And the compiler gives up on Zod 4’s type for an array nested dozens of levels deep, where it works out Zod 3’s.

It is written for regular Zod, with methods: "z.string().nullable().optional()". Zod Mini spells the same schema with functions instead, "z.optional(z.nullable(z.string()))", so Zod Mini cannot run the output as it stands.

Why the root comes last

The TypeScript page prints the root first and the objects it uses after it, since a type may be used before the line that declares it. A schema may not: it is a value, and a "const" read above its own declaration throws a ReferenceError while the module is still loading. So every schema here comes after each schema it uses, and the root comes last — Customer and LineItem first in the example, then the Root that holds them — which is why the root that opens the TypeScript page closes this one.

Such an order always exists. The inference is a tree, each object it extracts used from exactly one place, so no schema needs to refer to itself or to one printed after it, and the output never needs "z.lazy".

Frequently asked questions

Why is an id "z.number()" rather than "z.number().int()"?
Because a sample can show that every id so far was whole, but not that the next one will be. The page says so instead: the whole-numbers finding lists each position where every number was whole, and where you know a value must stay whole, adding ".int()" is a one-word edit. The finding is left out where a number is an integer outside the safe range, since Zod 4’s ".int()" would reject that sample itself.
Why does an email address come out as plain "z.string()"?
Because a string that looks like an email in your sample says nothing about the next one, and a format check that is right has to follow Zod’s own patterns, which change between versions — Zod 4’s "z.uuid()" already turns away strings that Zod 3’s ".uuid()" let through. Dates, URLs and UUIDs stay strings for the same reason, and a field that only ever held a few values never becomes an enum or a literal. If you know the rule, write it in; the schema is plain code you own.
Why does my own sample fail on Zod 4?
It holds a number beyond what a JavaScript number can hold — 1e999, say — which became Infinity or -Infinity when the JSON was parsed, and Zod 4’s "z.number()" rejects an infinite number where Zod 3’s accepts it. The Infinity finding names every position involved. Short of that, a schema always accepts the sample it came from, since every value in the sample went into it, and the repository checks that on both versions over a corpus of samples.
Why did a field I sent disappear from the parsed result?
Because the schema does not list it. "z.object" accepts an object with extra keys and returns it without them, on either version, and a key your sample lacked is a key the schema never learned. Add it to the schema, or make that one object loose — "z.looseObject" on Zod 4, ".passthrough()" on Zod 3 — if unknown keys should come through untouched.
I moved one schema below another and got a ReferenceError. Why?
Because a schema is a value, and JavaScript will not let a value be read above the line that defines it. The page prints each schema after every schema it uses, with the root last, for exactly that reason; keep a schema above everything that refers to it and the error goes away.
Why is the schema called Customer and not CustomerSchema?
That is zod.dev’s own convention: a schema and the type it infers share one name, which TypeScript allows because values and types live in separate namespaces. The name itself is the one the JSON to TypeScript page gives the same object, so it is the same word on both pages, for the schema and for its type alike.
Which version of Zod is the output written for?
Zod 4, using nothing Zod 3 lacks, so it runs unchanged on either. The two differ in a few places — an infinite number, a key past the depth limit, a key spelled "__proto__" and the type of a very deeply nested array — and each is explained above. It is regular Zod with chained methods; Zod Mini spells the same schema with functions and cannot run it as it stands.
Can I paste real data, credentials and all?
Yes. The schema is worked out in your browser: what you paste is read on your own machine and is not sent to a server, stored or logged. The schema also holds none of your values, only your keys and the kind of value under each, so a token in the sample comes out as "z.string()" and nothing more.

Related tools

  • JSON to TypeScript

    A schema from this page runs as part of your code and checks the data each time it arrives. That page prints the same answer for the same JSON as plain TypeScript types, name for name, which are checked when your code compiles and add nothing at run time.

  • JSON to Go

    This page leaves it to you to require whole numbers where every number in your sample was one. That page works out the same shape under the same type names, but Go has no type that is simply a JSON number, so where every number is written whole it types the field as an integer, and a value written with a decimal point will not decode into it.

  • JSONPath tester

    Test RFC 9535 JSONPath queries against JSON.

  • Markdown table generator

    Build and align Markdown tables from CSV, TSV or JSON.