{ } Schemato

Guide

How to convert JSON to a Zod schema

A 5-minute, copy-paste-friendly walkthrough: from a raw JSON sample to a schema you can use for Zod validation, TypeScript inference, forms, and tRPC.

Need it now? Skip ahead to the JSON → Zod converter.


Step 1 — Get a representative JSON sample

Copy a real response from the network tab, a webhook fixture, or your test data. The schema is only as accurate as the sample you start from, so prefer a payload that includes optional fields and arrays you care about.

{
  "id": 42,
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "isAdmin": false,
  "tags": ["math", "engine"]
}

Step 2 — Generate the schema

Paste the sample into the JSON → Zod converter. You'll get something like this:

import { z } from "zod";

export const User = z.object({
  "id": z.number().int(),
  "name": z.string(),
  "email": z.string().optional(),
  "isAdmin": z.boolean(),
  "tags": z.array(z.string()),
});

export type User = z.infer<typeof User>;

Note the last line: z.infer<typeof User> gives you a TypeScript type derived from the schema, so the type and the runtime validator can never drift apart.

Step 3 — Refine field constraints

The generator infers types but not business rules. Tighten the schema with Zod's built-in refinements:

import { z } from "zod";

export const User = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  email: z.string().email().optional(),
  isAdmin: z.boolean(),
  tags: z.array(z.string()).default([]),
});

export type User = z.infer<typeof User>;

Step 4 — Add Zod validation at trust boundaries

The biggest win from a Zod schema is catching shape changes the moment they cross a boundary you do not fully control. Use .parse() when you expect valid data and want to fail loudly, or .safeParse() when you want to handle the failure inline.

import { User } from "./schemas";

export async function fetchUser(): Promise<User> {
  const res = await fetch("/api/me");
  const data = await res.json();
  return User.parse(data); // throws ZodError on shape mismatch
}

Zod validation checklist

  • • Validate values from HTTP APIs, webhooks, forms, env vars, localStorage, and message queues.
  • • Skip validation for values that already came from your own typed functions.
  • • Prefer safeParse() when the user can recover from bad input.
  • • Prefer parse() when bad input should stop the request immediately.

Step 5 — Reuse the schema in forms

The same schema can drive client-side form validation. With @hookform/resolvers/zod the integration is two lines:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "./schemas";

export function UserForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(User),
  });
  return (
    <form onSubmit={handleSubmit((v) => console.log(v))}>
      <input {...register("name")} />
      {errors.name && <span>{errors.name.message}</span>}
    </form>
  );
}

Step 6 — Reuse in tRPC / API contracts

If you ship tRPC, pass the schema (or a transformed copy) directly as the procedure input. Same shape, same validator, same TypeScript types — end-to-end:

import { z } from "zod";
import { publicProcedure, router } from "./trpc";

export const userRouter = router({
  create: publicProcedure
    .input(User.omit({ id: true })) // reuse the schema, drop the server-generated id
    .mutation(async ({ input }) => {
      // input is fully typed and runtime-validated
      return db.user.create({ data: input });
    }),
});

Common pitfalls

Troubleshooting generated Zod schemas

A generated schema is a strong first pass, but runtime validation gets strict quickly. These are the places developers most often need to edit the output before shipping it.

Optional vs nullable fields

JSON samples only show the value you pasted. If a field appears as null, the generator can mark it nullable, but it cannot know whether the API may also omit that field.

// Generated from a sample where email was null
email: z.string().nullable()

// Use this when the API may omit email entirely
email: z.string().email().optional()

// Use this when the API may send null or omit the field
email: z.string().email().nullable().optional()

Numbers that arrive as strings

Browser forms, query strings, CSV imports, and some APIs send numbers as strings. Use coercion only at input boundaries where you expect that shape; keep internal data strict.

const User = z.object({
  // Useful when a form field sends "42" as a string
  id: z.coerce.number().int().positive(),

  // Keep API timestamps as strings unless your app needs Date objects
  createdAt: z.string().datetime(),
});

Empty arrays and mixed arrays

Empty arrays usually become z.array(z.unknown()) because there is no element to inspect. Mixed arrays may also need manual review: decide whether the API really allows multiple shapes or whether your sample is combining unrelated data.

Dates, IDs, and enums

JSON has strings, numbers, booleans, arrays, objects, and null. It does not have native dates, UUIDs, or enums. Add .datetime(), .uuid(), or z.enum([...]) after generation when your domain requires tighter validation.

FAQ

Do I need to learn Zod's API to use this?

No. The generator produces a working schema you can drop in. Reading the Zod docs only matters when you want to add custom rules.

Will the schema include all my optional fields correctly?

Optionality is inferred from the sample. Review the output and add .optional() where appropriate.

Should I use Zod or just TypeScript types?

Use Zod when you need runtime validation; use plain TypeScript when the value comes from a statically-typed source you already trust.

Can I generate Zod from JSON Schema or OpenAPI instead?

Yes — use JSON Schema → Zod or OpenAPI → Zod.

What is the difference between optional and nullable?

.optional() means the field may be missing or undefined. .nullable() means the field may be explicitly null. Some API responses need both.

Why does the output include z.unknown()?

Usually because the sample has an empty array or a mixed value that cannot prove a stable type. Paste a more representative sample, then tighten the generated schema by hand where needed.

Related