Convert a JSON Schema to a Zod schema and TypeScript types

Paste a JSON Schema (draft-07 or 2020-12) and get Zod 4 or Zod 3 code, optional TypeScript types, and a table of the keywords that could not be converted.

How it works

  1. Paste your JSON Schema
  2. Pick Zod 4 or Zod 3 and a name for the root schema
  3. Copy the Zod code and the TypeScript types, and read the list of keywords not applied

How a JSON Schema becomes a Zod schema

To convert a JSON Schema to Zod, map each type to its Zod builder and each constraint to a method: type string becomes z.string(), minLength 1 becomes .min(1), and a property that is not in required gets .optional(). This tool does that walk for you. Integers become z.number().int() in Zod 3. In Zod 4 they become z.number() with a Number.isInteger check, because Zod 4 int() stops at the safe-integer range (about 9 quadrillion) and a JSON Schema integer does not. In Zod 3, minLength and maxLength are written as code-point checks, because Zod 3 counts an emoji as two characters and JSON Schema counts it as one. A keyword with no type next to it, such as minLength alone, applies only to its own kind of value, so the tool writes a union that still accepts other types and lists it in the table. A type list such as ["string", "null"] becomes z.string().nullable(), and several non-null types become z.union([...]). An enum of strings becomes z.enum([...]), a mixed enum becomes a union of z.literal values, and const becomes z.literal. A pattern becomes .regex(), written as a regex literal, or as new RegExp when the pattern contains a slash. A description is kept as .describe() unless you switch that off.

Objects, references and combinators

Objects become z.object. With additionalProperties false you get z.strictObject in Zod 4 and .strict() in Zod 3, with true you get z.looseObject or .passthrough(), and with a schema you get .catchall(). When additionalProperties is absent it is treated as true, because JSON Schema allows extra keys by default, so you get z.looseObject or .passthrough() and unknown keys are kept. An object with no properties becomes z.record(z.string(), ...). Local references that start with # are followed: each referenced definition is written once as its own const, placed before the schema that uses it, so address in $defs becomes AddressSchema. A schema that refers to itself, directly or through #, uses z.lazy and an annotated const, and the matching TypeScript type is needed for that annotation. anyOf and oneOf become z.union, allOf becomes z.intersection, and a node with its own type plus anyOf is intersected. A prefixItems tuple allows extra elements unless items is false. Keywords next to a $ref are applied in 2019-09 and 2020-12 (and when no $schema is given), and ignored with a note when $schema is draft-07 or older.

What is not converted

Some keywords have no Zod equivalent, and the page lists each one with its place in the schema instead of dropping it silently: not, if, then, else, patternProperties, propertyNames, dependentRequired, dependentSchemas, contains, uniqueItems, minProperties, maxProperties, unevaluatedProperties, contentEncoding and a few more. oneOf is written as a union, so Zod does not check that exactly one option matches. A string format other than email, uuid, uri, date-time and date, such as ipv4, stays a plain string and is listed. In Zod 4 a format is not combined with minLength, maxLength or pattern, and the tool says so when that happens. Remote references and references to places that do not exist become z.unknown(). Definitions that nothing refers to are not written.

The TypeScript types

The types are written straight from the schema, not from Zod, so they work without it. Optional keys get a question mark, enums become unions of literals, tuples become tuple types and additionalProperties is shown only when an object has no properties, as a Record type with string keys. If you switch the types off, the Zod code ends with an export type line that uses z.infer on the schema. Check the result against your own data before you rely on it: the tool converts structure, it does not run your schema. It reads up to 500,000 characters of JSON.

Frequently asked questions

How do I convert a JSON Schema to a Zod schema?

Paste the schema and copy the Zod code. type string becomes z.string(), integer becomes a z.number() integer check, required controls .optional(), and local $ref values become separate consts such as AddressSchema.

What is the difference between the Zod 4 and Zod 3 output?

Zod 4 uses top-level builders: z.email(), z.uuid(), z.url(), z.iso.datetime() and z.strictObject(). Zod 3 uses methods on strings and numbers: z.string().email() and .strict(). Zod 3 string lengths are written as code-point checks, and Zod 4 integers use a Number.isInteger check. Pick the version your project has installed.

How does it handle $ref and recursive schemas?

Local references starting with # are followed. Each definition is written once, before it is used. A schema that refers to itself, such as a tree with children, is written with z.lazy and typed with z.ZodType, which needs the TypeScript type from the second box.

Why is my oneOf not exactly one in Zod?

Zod has no exactly-one union. oneOf is written as z.union([...]), which accepts a value that matches several options too. The page lists this under Keywords not applied, so you can add a .refine() if you need the strict rule.

Why does Zod accept extra keys my schema does not allow?

z.object removes unknown keys when it parses instead of rejecting them. If your schema says additionalProperties false, the tool writes z.strictObject (Zod 4) or .strict() (Zod 3), which reject them. Without that keyword, or with true, the tool writes z.looseObject (Zod 4) or .passthrough() (Zod 3), which keep extra keys, because JSON Schema allows them.

Is my schema uploaded?

No. It is processed in your browser, in this tab, and no server receives it. Your version, name and switches are saved in this browser, the schema is not.