Skip to content
Salyro
Tool

JSON Schema to Zod, Pydantic and TypeScript

Convert JSON Schema to Zod, Pydantic v2 or TypeScript types and back, with every keyword or construct that does not convert exactly listed.

What it does

Paste a JSON Schema and get the same shape as Zod schemas, Pydantic v2 models or TypeScript types. Or paste Zod, Pydantic or TypeScript code and get the JSON Schema it describes. Under both sits a list of everything that did not convert exactly: what was left out, what was converted with a loss, and where the target checks a value differently from JSON Schema. Each entry says where it is, and Show selects it in your input.

Everything runs in your browser. Nothing you paste is sent anywhere, and code is read, never run: the tool splits it into tokens and reads its declarations. Nothing in it is evaluated, imported or called.

Each direction has a fixed address that opens it with an example. Your own text in the field is never replaced by one: if it is in the language that direction reads, the direction changes and your text stays; if it is not, nothing changes, and the tool offers to open the example:

Convert back turns the output into the input and converts it the other way, so you can see a round trip. Check provider compatibility opens the JSON Schema compatibility checker with the schema, carried in the address.

What it reads

JSON Schema (2020-12): type, a single type or a list of types, properties, required, additionalProperties (as false, or as a schema on an object with no properties), items, enum, const, anyOf, oneOf, $ref to # or to the root's $defs or definitions, description, default, the string length limits, pattern, the number bounds, multipleOf, and the item count limits. The schema can be bare, or inside a response format or a tool definition, the way the compatibility checker reads it.

Zod: const declarations built from z.object (and z.strictObject), z.string, z.number, z.int, z.boolean, z.null, z.unknown, z.array, z.enum, z.literal, z.union, z.discriminatedUnion, z.record and z.lazy. It also reads the methods that only describe a value, when their arguments are literal: .min, .max, .length, .regex, .gte, .gt, .lte, .lt, .int, .positive, .multipleOf, .nullable, .optional, .nullish, .describe, .default and .strict. A reference to another const in the same input becomes a $ref, and so does Zod 4's recursive getter form.

Pydantic v2: class X(BaseModel) with annotated fields, RootModel[…], Enum classes with literal values, and type aliases. Types: str, int, float, bool, None, Any, list[…], dict[str, …], Optional[…], X | None, Union[…], Literal[…], Annotated[…, Field(…)], and the other classes of the input, quoted forward references included. It reads Field(…) with literal default, alias, description and constraint arguments, model_config = ConfigDict(extra="forbid"), and one base model's inherited fields.

TypeScript: interface and type declarations with primitives, string, number and boolean literals, arrays, object types with optional keys, Record<string, T>, unions, | null, and references to the other declarations. A /** … */ comment becomes the description.

From code, the root of the schema is the last declaration no other one uses. Every other declaration goes under $defs. You can choose another root above the input.

How it writes

  • Zod: one exported const per declaration, each after the ones it uses, in the API Zod 3 and Zod 4 share. A recursive schema is z.lazy, with a TypeScript type beside it, because TypeScript cannot infer a type that refers to itself. A key that is not a name is quoted, and __proto__ is written as a computed key so that it is a key and not the prototype.
  • Pydantic v2: an object is a BaseModel. An object nested in another becomes a model of its own, named after its key. A key that is not a Python name, such as first-name, class, json, 2fa or a model_ name, gets a field name chosen the same way every time, and Field(alias=…) keeps the key. Recursion goes through models, with from __future__ import annotations and model_rebuild(). A key that may be left out and is not nullable is written = None. Pydantic does not check a default, so a missing key is accepted and an explicit null is still refused, as the schema says.
  • TypeScript: an interface for each object and a type for everything else, with each description as JSDoc.

A declaration whose name the target cannot use as it is, such as a-b, class or string, gets a name of its own, and the list says which.

A default is written only when it is a value of its own schema: Zod would not compile any other, and Pydantic would fill it in without checking it. Any other is left out, and the list says why. Lengths are counted in characters, as JSON Schema and Pydantic count them; for Zod they are also held to UTF-16 units, since Zod 3 counts those. For Zod, a default object may not have a key its object does not name, because a Zod object literal refuses one. A pattern is never run here, so a default a pattern applies to is written with a note to check it yourself. The check has a depth bound and one time budget for all the defaults of a conversion; a default it cannot finish checking is left out, and the list says it could not be checked. In a large schema with many defaults, the later ones may be left unchecked this way. So is a default that may be one of the enum values the tool does not convert, which are objects and lists.

Every string is escaped for the language it is written in: quotes, backslashes, line breaks, */ in a comment, and """ in a docstring. ${ is never special, because no template literal is ever written. The output is text for you to copy. It is never run here.

What it does not convert

These keywords are named where they are, and the output does not check them: allOf, not, if/then/else, patternProperties, propertyNames, the dependent keywords, the unevaluated keywords, minProperties, maxProperties, prefixItems, contains, uniqueItems, format, the content keywords, $dynamicRef, $anchor, and a $ref to anything other than the root's definitions. A reference to another document is never fetched.

  • oneOf is written as a union, which also accepts a value that matches more than one branch. The list says so.
  • format is left out. Zod's .email() and Pydantic's EmailStr each check their own definition, and Salyro's validator does not check formats at all.
  • OpenAPI's nullable is not a JSON Schema keyword. A JSON Schema validator ignores it, so the tool does not turn it into null.
  • TypeScript has no run-time checks. Lengths, bounds, patterns, additionalProperties: false and defaults are listed as not written, and integer becomes number.
  • Code that runs is not read. That covers Zod's .refine, .transform, .pipe and a default that is a function. It covers Pydantic's validators, computed fields, methods and default_factory. It also covers imports from other files, spreads, generics, intersections, tuples, enums in TypeScript, and decorators. Each is reported where it is, and nothing is guessed in its place: a name that is not declared in the input becomes "any value".
  • What JavaScript reads differently from how it looks is reported, not read. In a Zod shape, __proto__: … sets the object's prototype and adds no key; ["__proto__"]: … is the key. In TypeScript, {} is any value except null and undefined, not an empty object. z.date() is a JavaScript Date, which JSON has no type for. A default or a literal that is part of a larger expression, such as 1 + 1 or 1 if X else 2, is not read at all.
  • Text Pydantic cannot hold: half of a surrogate pair on its own is valid in JSON, but Pydantic cannot build a model with one. It is written as U+FFFD, and the list says where.
  • Annotations that constrain nothing, such as title, examples, $comment and deprecated, are not carried over.

Where the targets check differently

A conversion can be exact in shape and still be checked differently. When it is, the list says so and does not claim the two are the same:

  • Pydantic's lax mode converts some values JSON Schema refuses, such as the string "12" or true to a number and "true" to a boolean. ConfigDict(strict=True) refuses those conversions, and it also refuses 1.0 for an integer, which JSON Schema accepts.
  • Pydantic's Literal compares with Python's ==, so Literal[1] also accepts true.
  • Pydantic's pattern is checked with the Rust regex engine by default. JSON Schema uses ECMAScript regular expressions. Look-around and backreferences are refused when the model is defined, and classes can match other characters.
  • Zod's .int() in Zod 4 also refuses whole numbers beyond the safe-integer range. JSON Schema's integer has no bound.
  • String lengths: JSON Schema counts characters. So does Zod 4, but Zod 3 counts UTF-16 units, so an emoji counts as 2 there.
  • Keys every JavaScript object inherits, such as constructor or toString. Zod and Ajv, the validator Salyro checks with, find a function there on an object without the key. An optional one with a type is then refused when it is missing, which JSON Schema accepts; a required one that may be any value counts as present, which JSON Schema refuses.
  • A required key of any value is written z.unknown(), which Zod 3 also accepts when the key is missing. Read from Zod, a z.unknown() or z.any() key is required, as Zod 4 and JSON Schema check it, and the list notes Zod 3's difference.
  • A key named __proto__ is skipped by Ajv and by Zod 4, so any value passes there. Zod 3 and Pydantic check it.
  • Keys a schema does not name are accepted by both targets, as by the schema, but Zod leaves them out of the value it returns, and so does Pydantic by default.

FAQ

Does a round trip give back the same schema?

Within the subset it reads, yes: the same types, keys, requirements, values, constraints and descriptions. Where a target cannot hold something, the first conversion says so. The tests hold this to fixed examples in all three languages and to hundreds of generated schemas. They also hold every converted fixture to its golden output, character for character.

Why does the Pydantic output use = None on a field that is not nullable?

Because a key that may be left out and a key that may be null are different things in JSON Schema. str | None = None would also accept null. The list notes it, because a type checker flags it.

Is the output checked with Zod, Pydantic or the TypeScript compiler?

The output is never run in your browser. When the tool was written, the fixtures' output was compiled and run with Zod 4.5.4 and Pydantic 2.13.5, outside the site. Each was checked against the same values as Salyro's validator, and every difference found is one the list names. The tests also compile the Zod and TypeScript output with the TypeScript compiler, in strict mode, against Zod 4's own types.

Sources

JSON Schema 2020-12, the validation specification: string length is the number of characters, and pattern is an ECMAScript regular expression that is not implicitly anchored. The Zod behaviour stated here was read from the source of Zod 4.5.4, and of the Zod 3 it ships. The Pydantic behaviour was observed by running Pydantic 2.13.5. Zod's and Pydantic's documentation pages could not be read from the build environment.

Where the numbers come from

This tool works only on what you enter. It uses no external data and runs entirely in your browser.