Type-Safe JSON Validation in TypeScript with Zod
TypeScript gives you compile-time type safety, but those types vanish the moment your code runs. When your application receives JSON from an API, a form submission, or an environment variable, TypeScript has no idea whether the data actually matches the type you declared. This is where Zod comes in. Zod lets you define schemas that validate data at runtime and infer TypeScript types automatically, giving you a single source of truth for both validation and type definitions.
The Gap Between TypeScript Types and Runtime Reality
Consider a common pattern in a Next.js application. You fetch user data from an API and type the response:
interface User {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
}
const res = await fetch("/api/users/123");
const user: User = await res.json();TypeScript is happy. But what happens if the API returns role: "superadmin", or id as a string, or omits email entirely? Your code will not throw an error at the fetch — it will throw somewhere downstream, when you try to use user.email.toLowerCase() on undefined. The error message will be cryptic and far from the actual problem.
Zod solves this by validating the shape of the data at the point where it enters your application. If the data does not match, you get a clear, structured error immediately.
Zod Basics
A Zod schema describes the expected shape of your data. The basic building blocks are primitives, objects, arrays, and unions:
import { z } from "zod";
// Primitives
const nameSchema = z.string().min(1).max(100);
const ageSchema = z.number().int().positive();
const activeSchema = z.boolean();
// Objects
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof userSchema>;
// Validate
const result = userSchema.safeParse(unknownData);
if (result.success) {
// result.data is typed as User
console.log(result.data.name);
} else {
// result.error contains structured validation errors
console.error(result.error.issues);
}The key insight is the z.infer utility. You define the schema once, and TypeScript extracts the type from it. No more maintaining an interface and a validation function separately — the schema is the type.
Generating Zod Schemas from JSON Samples
When you are working with an API you do not control, you often start with a sample JSON response and need to build a schema from it. Doing this by hand means reading through every field and deciding the right Zod type. For complex nested responses, this is slow. A generator can produce the initial schema in seconds.
Take this API response:
{
"order_id": "ord_7a3b2c",
"status": "shipped",
"total": 149.97,
"currency": "USD",
"items": [
{
"sku": "WM-001",
"name": "Wireless Mouse",
"quantity": 2,
"price": 29.99
},
{
"sku": "HB-042",
"name": "USB-C Hub",
"quantity": 1,
"price": 49.99
}
],
"shipping": {
"carrier": "USPS",
"tracking_number": "9400111899223456789012",
"estimated_delivery": "2026-05-08"
},
"created_at": "2026-05-01T10:30:00Z"
}A generator produces:
import { z } from "zod";
const itemSchema = z.object({
sku: z.string(),
name: z.string(),
quantity: z.number(),
price: z.number(),
});
const shippingSchema = z.object({
carrier: z.string(),
tracking_number: z.string(),
estimated_delivery: z.string(),
});
const orderSchema = z.object({
order_id: z.string(),
status: z.string(),
total: z.number(),
currency: z.string(),
items: z.array(itemSchema),
shipping: shippingSchema,
created_at: z.string(),
});
type Order = z.infer<typeof orderSchema>;This is correct but loose. Now you refine it: add .min(1) to quantity, z.enum for status and currency, and z.string().datetime() for the timestamp.
Try it yourself
Paste a JSON sample and get a Zod schema instantly. Open the JSON to Zod tool →
Example 1: Validating API Responses in Next.js
Here is how to use a Zod schema to validate an API response in a Next.js server component:
import { z } from "zod";
const postSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
tags: z.array(z.string()),
published: z.boolean(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().url(),
}),
});
const postsResponseSchema = z.array(postSchema);
type Post = z.infer<typeof postSchema>;
async function getPosts(): Promise<Post[]> {
const res = await fetch("https://api.example.com/posts");
const json = await res.json();
// This throws if the response shape is wrong
return postsResponseSchema.parse(json);
}If the API changes its response format — renames a field, changes a type, or removes a property — the parse call throws immediately with a clear error message. You find out about the breaking change in your server logs, not from a user reporting a blank page.
For cases where you want to handle the error gracefully instead of throwing, use safeParse:
const result = postsResponseSchema.safeParse(json);
if (!result.success) {
console.error("API response validation failed:", result.error.flatten());
// Return cached data, show error UI, or fall back
return getCachedPosts();
}
return result.data;Example 2: Form Validation with React Hook Form
Zod integrates cleanly with React Hook Form through the @hookform/resolvers package. You define the schema once and use it for both client-side validation and type inference:
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email"),
subject: z.enum(["general", "support", "billing", "partnership"]),
message: z.string()
.min(10, "Message must be at least 10 characters")
.max(2000, "Message must be under 2000 characters"),
subscribe: z.boolean().default(false),
});
type ContactForm = z.infer<typeof contactSchema>;
function ContactPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactForm>({
resolver: zodResolver(contactSchema),
});
const onSubmit = (data: ContactForm) => {
// data is fully validated and typed
fetch("/api/contact", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
{/* ... */}
</form>
);
}The schema provides both the validation rules and the TypeScript type. When you add a new field to the schema, TypeScript immediately tells you everywhere in the form that needs to use it. When you change a validation rule, it takes effect in both the client-side validation and the type definition. There is no way for them to drift apart.
Example 3: Environment Variable Validation
One of the most practical uses of Zod is validating environment variables at application startup. Without validation, a missing or malformed env var causes a confusing error deep in your application. With Zod, you catch it immediately:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
API_KEY: z.string().min(20),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(["development", "staging", "production"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_CACHE: z.coerce.boolean().default(true),
});
// Validate at startup — throws if any variable is missing or invalid
export const env = envSchema.parse(process.env);
// TypeScript knows the exact type of every variable
// env.PORT is number, env.NODE_ENV is "development" | "staging" | "production"
// env.ENABLE_CACHE is boolean (coerced from string)Note the use of z.coerce — environment variables are always strings, so z.coerce.number() converts the string to a number before validation, and z.coerce.boolean() converts "true" and "false" to booleans. The .default() method provides fallback values for optional variables.
Put this in a file like env.ts and import env throughout your application instead of accessing process.env directly. Every variable is validated, typed, and has a sensible default.
Try it yourself
Generate TypeScript interfaces alongside your Zod schemas. Open the JSON to TypeScript tool →
Composing and Extending Schemas
Zod schemas are composable. Once you have a base schema, you can extend, merge, pick, and omit fields to create variations without duplicating code:
// Base schema generated from a JSON sample
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
created_at: z.string().datetime(),
});
// For creating a user (no id or created_at)
const createUserSchema = userSchema.omit({
id: true,
created_at: true,
});
// For updating a user (all fields optional)
const updateUserSchema = userSchema.partial().omit({
id: true,
created_at: true,
});
// Extend with additional fields
const userWithPostsSchema = userSchema.extend({
posts: z.array(z.object({
id: z.number(),
title: z.string(),
})),
});
// Merge two schemas
const profileSchema = z.object({
bio: z.string().max(500),
avatar_url: z.string().url().optional(),
});
const fullUserSchema = userSchema.merge(profileSchema);This composability means you generate the base schema once and derive all your variants from it. When the base changes, all derived schemas update automatically.
Error Handling
Zod provides structured error objects that you can format for different audiences. The raw ZodError contains an array of issues, each with a path, code, and message. For API error responses, .flatten() and .format() are particularly useful:
const result = orderSchema.safeParse(requestBody);
if (!result.success) {
// .flatten() groups errors by field name — great for form errors
const flat = result.error.flatten();
// flat.fieldErrors: { status?: string[], total?: string[], ... }
// flat.formErrors: string[] (top-level errors)
return Response.json({
error: "Validation failed",
fields: flat.fieldErrors,
}, { status: 400 });
}
// .format() creates a nested error object matching the schema shape
const formatted = result.error.format();
// formatted.items?._errors: string[]
// formatted.shipping?.carrier?._errors: string[]For user-facing messages, you can customize error messages directly in the schema definition using the second argument to most validators: z.string().min(1, "This field is required").
Try it yourself
Generate a JSON Schema for API documentation from the same data. Open the JSON Schema Generator →
When to Use Zod vs Other Approaches
Zod is the right choice when you want validation and TypeScript types from the same source, which is most of the time in a TypeScript project. If you already have a JSON Schema (from an OpenAPI spec, for example), libraries like ajv can validate against it directly. If you only need TypeScript types without runtime validation, plain interfaces are fine. But if you are building a TypeScript application that receives JSON from anywhere — APIs, forms, databases, files, environment variables — Zod provides the best developer experience for ensuring that data is what your code expects.
Further Reading
For the complete Zod API, including transforms, pipes, branded types, and integration recipes, see the official Zod documentation.