Zod Schema Generation from JSON: A Practical Guide

12 min readTypeScript & Validation

Introduction

Zod has become the go-to validation library for TypeScript developers. It lets you define schemas that validate data at runtime while automatically inferring TypeScript types. This guide shows you how to generate Zod schemas from JSON data, so you can quickly add validation to any API response or data source without writing schemas by hand.

What is Zod?

Zod is a TypeScript-first schema validation library. Unlike Joi or Yup, Zod was designed from the ground up for TypeScript, providing perfect type inference. Key features:

  • TypeScript-first - Full type inference with zero manual type annotations
  • Runtime validation - Catches invalid data that TypeScript can't
  • Composable - Build complex schemas from simple primitives
  • Zero dependencies - Small bundle size, no external deps
  • SSR compatible - Works in Node.js and browsers

Generating Zod Schemas from JSON

The fastest way to create a Zod schema from JSON data is using ourJSON to TypeScript & Zod tool. Paste your JSON, and it generates both TypeScript interfaces and Zod schemas.

Given this JSON:

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com",
  "isActive": true,
  "address": {
    "street": "123 Main St",
    "city": "San Francisco"
  }
}

The tool generates this Zod schema:

import { z } from "zod";

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
});

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
  isActive: z.boolean(),
  address: AddressSchema,
});

Deriving TypeScript Types from Zod

The biggest advantage of Zod is that you never write TypeScript types manually. Use z.infer to derive types from your schemas:

// Define the schema once
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// TypeScript type is automatically inferred
type User = z.infer<typeof UserSchema>;
// Equivalent to: { id: number; name: string; email: string }

// Use it in your code
function updateUser(user: User) {
  // TypeScript knows the exact shape
  console.log(user.name); // string
  console.log(user.email); // string
}

This is the "single source of truth" pattern: define your schema once, and both runtime validation and compile-time types come from it automatically.

Advanced Zod Patterns

Optional and Default Values

const ConfigSchema = z.object({
  port: z.number().default(3000),
  host: z.string().default("localhost"),
  debug: z.boolean().default(false),
  apiKey: z.string().optional(),    // string | undefined
  timeout: z.number().nullable(),   // number | null
});

Validation Rules

const UserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  website: z.string().url().optional(),
  role: z.enum(["admin", "user", "moderator"]),
});

Coercion (String to Number)

// When data comes from forms or query params (always strings)
const SearchParamsSchema = z.object({
  page: z.coerce.number().default(1),     // "2" -> 2
  limit: z.coerce.number().default(20),   // "50" -> 50
  sort: z.string().default("created_at"),
  active: z.coerce.boolean().default(true), // "true" -> true
});

Discriminated Unions

const TextMessage = z.object({
  type: z.literal("text"),
  content: z.string(),
});

const ImageMessage = z.object({
  type: z.literal("image"),
  url: z.string().url(),
  width: z.number(),
  height: z.number(),
});

const Message = z.discriminatedUnion("type", [TextMessage, ImageMessage]);

// Zod validates based on the "type" field
Message.parse({ type: "text", content: "Hello" });     // OK
Message.parse({ type: "image", url: "...", width: 100, height: 100 }); // OK
Message.parse({ type: "text", url: "..." });            // Error!

Transform Data

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  created_at: z.string().transform(s => new Date(s)),
  metadata: z.string().transform(s => JSON.parse(s)),
});

type User = z.infer<typeof UserSchema>;
// created_at is Date, metadata is parsed object

Real-World Usage Patterns

Validating API Responses

async function fetchUsers(): Promise<User[]> {
  const res = await fetch("/api/users");
  const data = await res.json();

  // Validate the entire response
  const users = z.array(UserSchema).parse(data);
  return users;
}

// With error handling
async function safeFetchUsers() {
  try {
    const users = await fetchUsers();
    return { success: true, data: users };
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error("API response doesn't match schema:", error.errors);
      return { success: false, error: "Invalid data format" };
    }
    throw error;
  }
}

Form Validation with React

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

const LoginFormSchema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "Password must be 8+ characters"),
  remember: z.boolean().default(false),
});

type LoginForm = z.infer<typeof LoginFormSchema>;

function LoginForm() {
  const { register, handleSubmit } = useForm<LoginForm>({
    resolver: zodResolver(LoginFormSchema),
  });

  const onSubmit = (data: LoginForm) => {
    // data is fully validated and typed
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} type="email" />
      <input {...register("password")} type="password" />
      <input {...register("remember")} type="checkbox" />
      <button type="submit">Login</button>
    </form>
  );
}

Next.js API Routes

// app/api/users/route.ts
import { z } from "zod";

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user"]).default("user"),
});

export async function POST(request: Request) {
  const body = await request.json();

  const result = CreateUserSchema.safeParse(body);
  if (!result.success) {
    return Response.json(
      { errors: result.error.flatten() },
      { status: 400 }
    );
  }

  // result.data is fully typed and validated
  const user = await createUser(result.data);
  return Response.json(user);
}

Zod for LLM and agent JSON

The same Zod schemas you generate from API samples work as the trust boundary for model output. Treat chat completions, tool-call arguments, and skill handoff objects as untrusted JSON: parse, then safeParse or parse before you write to a database or run a side effect. Prefer closed enums and .strict() (or equivalent) when models invent extra keys.

For the full agent stack—structured outputs, repair vs retry, and MCP-style tool contracts—see LLM JSON structured output. Use this Zod guide for schema craft; use that guide for the model threat model.

const ToolArgs = z.object({
  title: z.string().min(3).max(120),
  priority: z.enum(["low", "medium", "high"]),
}).strict();

const result = ToolArgs.safeParse(JSON.parse(modelJsonText));
if (!result.success) {
  // fail closed for tools — do not execute
  return { error: result.error.flatten() };
}
await runTool(result.data);

Key Takeaways

  • Use Zod for runtime validation at API boundaries - TypeScript types alone aren't enough
  • Generate Zod schemas from JSON using our JSON to TypeScript & Zod tool
  • Always use z.infer<typeof Schema> to derive types - single source of truth
  • Use z.coerce when data comes from forms or query parameters
  • Use safeParse() instead of parse() when you want to handle errors gracefully
  • Combine with React Hook Form via @hookform/resolvers/zod for form validation
  • For LLM and agent tool payloads, fail closed after Zod errors—see the structured output guide

Related Resources

Z

Written by Zhisan

Independent Developer · Last updated June 2026