Wapgee Logowapgee

Tools / JSON Schema → TypeScript / Zod

JSON Schema → TypeScript & Zod

Paste a JSON Schema from an API spec or config contract and get a TypeScript interface plus a Zod schema you can copy into your project. Everything runs in the browser. Nothing is uploaded.

JSON Schema

From a contract to typed validators

JSON Schema is the lingua franca of API contracts, form builders, config files and agent tool definitions. It says what a payload is allowed to look like, but it says nothing your editor can use and nothing that runs at request time. TypeScript describes the shape while you write code; Zod checks that the data you actually received matches it. Keeping all three in step by hand is how a field ends up optional in one place and required in another.

This converter walks the schema once and emits both outputs from the same pass, so optional fields, enums, nested objects, arrays and internal $refs cannot disagree between them. It runs entirely in your browser: the schema is never uploaded, and only your last input and the option settings are kept in localStorage on this device so a refresh does not wipe your work.

A worked example

Paste a schema on the left, read TypeScript or Zod on the right. The Load example button fills in a realistic user profile if you want something to poke at first.

JSON Schema
{
  "title": "UserProfile",
  "type": "object",
  "required": ["id", "email"],
  "properties": {
    "id":       { "type": "string", "format": "uuid" },
    "email":    { "type": "string", "format": "email" },
    "age":      { "type": "integer", "minimum": 0 },
    "status":   { "type": "string", "enum": ["active", "invited"] },
    "nickname": { "type": ["string", "null"] }
  },
  "additionalProperties": false
}
TypeScript
export interface UserProfile {
  id: string;
  email: string;
  age?: number;
  status?: "active" | "invited";
  nickname?: string | null;
}
The same schema
(unchanged)
Zod
import { z } from "zod";

export const userProfileSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
  status: z.enum(["active", "invited"]).optional(),
  nickname: z.string().nullable().optional(),
}).strict();

Note what happened to the two fields that are not in required: they became ? in TypeScript and .optional() in Zod. additionalProperties set to false became .strict(), so an unexpected key is a validation error rather than a silent pass.

How schema keywords map to Zod

The converter targets the common draft-07 subset and tolerates 2020-12 documents. These are the mappings that come up most often:

JSON SchemaZod output
type: object + propertiesz.object({...}); keys outside required get .optional()
type: integerz.number().int()
enum: ["a","b"]z.enum(["a","b"]), or z.union for mixed types
type: ["string","null"] / nullable: true.nullable()
format: email|uuid|uri|date-time.email(), .uuid(), .url(), .datetime()
additionalProperties: false.strict()
oneOf / anyOfz.union([...]) and a TypeScript union
allOf of objectsMerged into one z.object; mixed allOf uses .and()
items as an array (tuple)z.tuple([...])
constz.literal(...) and a literal TypeScript type
description.describe(...) in Zod, a JSDoc comment in TypeScript
minLength / minimum / minItems.min() and .max() chains (see below)

Refs, definitions and circular types

Internal references are resolved and hoisted into named types. Anything under definitions, $defs or components.schemas is emitted as its own interface and its own Zod constant, so a shared Address appears once and is referenced everywhere it is used. A nested object with a title is hoisted the same way instead of being inlined as an anonymous shape.

A schema that refers to itself is the case that usually breaks generators. Here the cycle is detected while it is being walked and the Zod declaration is wrapped in z.lazy() with an explicit z.ZodType annotation, which is what Zod needs to type a recursive schema:

Self-referencing schema
{
  "title": "Tree",
  "type": "object",
  "properties": { "root": { "$ref": "#/$defs/Node" } },
  "$defs": {
    "Node": {
      "type": "object",
      "required": ["id"],
      "properties": {
        "id": { "type": "string" },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/Node" }
        }
      }
    }
  }
}
Output (abridged)
export interface Node {
  id: string;
  children?: Node[];
}

export const nodeSchema: z.ZodType<Node> = z.lazy(() => z.object({
  id: z.string(),
  children: z.array(nodeSchema).optional(),
}));

External $refs (anything that starts with http) cannot be fetched from the browser, so they become unknown and a notice appears under the input. A $ref that points at a path the document does not contain gets the same treatment, named in the notice so you can see which one it was.

Options, notices and what is left out

Three options sit above the panes. Root name names the top-level type when the schema has no title, or overrides it when the title is not a good identifier. TS style switches between export interface and export type; unions and primitives always come out as a type because an interface cannot express them. Append z.infer adds export type Root = z.infer<typeof rootSchema>; to the Zod output, which is the right choice when you want Zod to be the single source of truth and would rather not keep the interface at all.

Constraints are the one place the two outputs deliberately differ. minLength, maximum, minItems and friends become .min() and .max() chains in Zod, and vanish in TypeScript, because the type system has no way to say "a string of at least one character". The generated TypeScript carries a comment saying so, so nobody reads the interface and assumes the bounds are enforced.

Keywords that are ignored rather than approximated: if, then, else, patternProperties and not. Each one raises a notice under the input instead of failing the conversion, because the rest of the schema is usually still worth generating. Input is capped at 500 KB, which is far more than any single schema object needs. Full OpenAPI documents are not expanded either: paste the individual schema object, or the components.schemas entry, that you care about.

Where it fits

  • Typing a third-party API whose vendor publishes a JSON Schema or an OpenAPI component but no TypeScript client.
  • Validating request bodies at the edge of a route handler, where a .parse() call turns unknown input into a typed value in one step.
  • Checking an agent tool definition: the arguments an LLM sends back are untrusted, and a Zod schema generated from the same JSON Schema you gave the model is the natural guard. Build the definition itself in the LLM tool schema builder and paste its raw JSON Schema tab in here.
  • Reviewing a schema someone else wrote. Reading the generated interface is faster than reading the schema, and the notices tell you which parts are not being enforced.

If what you have is a payload rather than a schema, the JSON, YAML and TypeScript converter infers types from an example document instead. If it started life as a spreadsheet export, the CSV and JSON converter will get you to JSON first.

FAQ

Which JSON Schema drafts are supported?

The converter targets the common draft-07 subset and tolerates 2020-12 documents. Supported highlights include objects, arrays/tuples, enums, const, oneOf/anyOf/allOf, nullable, formats, constraints, descriptions, and internal $refs. External $refs, if/then/else, patternProperties, and not are ignored with a notice.

Is my schema uploaded anywhere?

No. Parsing and codegen run entirely in your browser. The last input and options are kept in localStorage on your device only so a refresh does not wipe your work.

Which Zod version is the output for?

Generated schemas use Zod 4-style APIs (z.object, .optional, .nullable, .strict, z.lazy, z.infer, and format helpers like .email() / .uuid()). Adjust imports if your project pins an older major.

What about OpenAPI schemas?

OpenAPI component schemas that look like JSON Schema objects often work, including nullable: true. Full OpenAPI documents are not expanded, so paste the schema object (or $defs / components.schemas entry) you care about.