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": "[email protected]",
  "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);
}

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

Related Resources