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 objectReal-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.coercewhen data comes from forms or query parameters - Use
safeParse()instead ofparse()when you want to handle errors gracefully - Combine with React Hook Form via
@hookform/resolvers/zodfor form validation - For LLM and agent tool payloads, fail closed after Zod errors—see the structured output guide
Related Resources
Written by Zhisan
Independent Developer · Last updated June 2026