JSON to Zod Validation: Validate API Responses Step by Step
Quick answer: To validate JSON with Zod, map each JSON type to a Zod method (string->z.string(),number->z.number(), etc.), compose them into az.object()schema, then call.safeParse(json). The result is either typed valid data or a structured error. The JSON to Zod converter automates this for any JSON payload.
Every frontend developer has hit this bug: the API returned null where you expected a string, and your app blew up in production. TypeScript couldn't catch it because types don't exist at runtime. You need a validation layer between your fetch() call and your application logic.
Zod is that layer. This guide walks through converting raw JSON responses into validated, fully-typed TypeScript data — from the first z.string() to handling nested arrays and optional fields in real API payloads.
JSON Types to Zod: The Complete Mapping
Every value in a JSON response maps to a specific Zod validator. Here's the full reference:
| JSON Type | Example Value | Zod Equivalent | Notes | |
|---|---|---|---|---|
| string | "hello" | z.string() | Add .email(), .url(), .uuid() for format checks | |
| number (integer) | 42 | z.number().int() | JSON doesn't distinguish int vs float — Zod does | |
| number (float) | 3.14 | z.number() | Use .positive(), .min(), .max() for range checks | |
| boolean | true | z.boolean() | Strict — won't coerce "true" or 1 | |
| null | null | z.null() | For nullable fields, use z.string().nullable() instead | |
| array | [1, 2, 3] | z.array(z.number()) | Inner type must be specified | |
| object | {"a": 1} | z.object({ a: z.number() }) | Each key gets its own validator | |
| nested object | {"user": {"name": "..."}} | z.object({ user: z.object({...}) }) | Nest z.object() calls | |
| mixed array | [1, "two"] | z.tuple([z.number(), z.string()]) | Fixed-length with known types | |
| enum string | "active" | z.enum(["active", "inactive"]) | Rejects any string not in the list | |
| date string | "2026-05-18" | z.string().date() | Validates ISO date format | |
| datetime string | "2026-05-18T12:00:00Z" | z.string().datetime() | Validates ISO 8601 datetime | |
| optional field | field missing | z.string().optional() | string | undefined in TypeScript |
| nullable field | null | z.string().nullable() | string | null in TypeScript |
z.string() for a field that can be null — the parse fails, and people blame Zod instead of their schema.
Step 1: Start with a Real API Response
Forget theoretical examples. Grab an actual response from your API. Here's a typical user endpoint:
{
"id": 4821,
"username": "devguy",
"email": "dev@example.com",
"avatar_url": null,
"is_active": true,
"role": "editor",
"created_at": "2026-01-15T09:30:00Z",
"settings": {
"theme": "dark",
"notifications": true,
"language": "en"
},
"recent_posts": [
{ "id": 101, "title": "First Post", "published": true },
{ "id": 102, "title": "Draft", "published": false }
]
}
This has everything: primitives, a nullable field, a nested object, an array of objects, an enum-like string, and an ISO datetime. Good — that's what real APIs look like.
If you have a JSON response and want to skip manual schema writing, paste it into the JSON to Zod converter. It generates the full z.object() schema in one click.
Step 2: Build the Schema Bottom-Up
Start with the deepest nested types and work outward. This prevents one massive, unreadable schema.
Post schema (the array items):
import { z } from "zod";
const PostSchema = z.object({
id: z.number().int().positive(),
title: z.string().min(1),
published: z.boolean(),
});
Settings schema (the nested object):
const SettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]),
notifications: z.boolean(),
language: z.string().length(2), // ISO 639-1 codes are 2 chars
});
User schema (the top level):
const UserSchema = z.object({
id: z.number().int().positive(),
username: z.string().min(1).max(50),
email: z.string().email(),
avatar_url: z.string().url().nullable(), // Can be null
is_active: z.boolean(),
role: z.enum(["admin", "editor", "viewer"]),
created_at: z.string().datetime(),
settings: SettingsSchema,
recent_posts: z.array(PostSchema),
});
type User = z.infer<typeof UserSchema>;
The type User line extracts the TypeScript type from the schema. You don't write a separate interface User — it's inferred. Change the schema, the type updates. One source of truth.
Three things to notice:
avatar_urluses.nullable()because the API returnsnull, not omits the field.roleusesz.enum()instead ofz.string()— this catches typos like"edtor"at runtime.- Nested schemas (
SettingsSchema,PostSchema) are separate variables for readability and reuse.
Step 3: Parse the API Response
Now connect the schema to your fetch() call:
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
return UserSchema.parse(json); // Throws ZodError if validation fails
}
If the API returns data that doesn't match — say role is "superadmin" which isn't in your enum — Zod throws a ZodError at the boundary. Not three components deep where you're trying to render the role badge. At the fetch call. Where you can handle it.
For cases where you want to handle errors gracefully instead of throwing:
async function getUserSafe(id: number) {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
const result = UserSchema.safeParse(json);
if (!result.success) {
console.error("Validation failed:", result.error.flatten());
return null;
}
return result.data; // Fully typed User
}
.safeParse() never throws. It returns { success: true, data: User } or { success: false, error: ZodError }. Use this for API routes where you want to return a 400 with specific field errors instead of crashing.
Step 4: Handle the Messy Parts
Real APIs have messy edges. Here's how to handle the three most common ones.
Numeric strings — some APIs return numbers as strings (looking at you, every payment API):
const PriceSchema = z.object({
amount: z.string()
.transform((val) => parseFloat(val))
.pipe(z.number().positive()),
currency: z.string().length(3),
});
// Input: { amount: "29.99", currency: "USD" }
// Output: { amount: 29.99, currency: "USD" } (number, not string)
The .transform() converts the string to a number, and .pipe() validates the result. If someone sends "not-a-number", parseFloat returns NaN, and z.number() rejects it.
Date strings to Date objects:
const EventSchema = z.object({
name: z.string(),
start: z.string().datetime()
.transform((val) => new Date(val)),
end: z.string().datetime()
.transform((val) => new Date(val)),
});
Unknown/dynamic keys — when the API returns an object whose keys you can't predict:
const MetadataSchema = z.record(z.string(), z.union([
z.string(),
z.number(),
z.boolean(),
]));
// Validates: { "source": "web", "version": 3, "beta": true }
// Rejects: { "nested": { "deep": "value" } }
z.record() validates key-value pairs where keys are strings and values match the given schema. Think of it as TypeScript's Record<string, T> but with runtime checking.
Handling Paginated API Responses
Most real APIs return paginated data. Here's a reusable pattern:
function paginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
data: z.array(itemSchema),
pagination: z.object({
page: z.number().int().positive(),
per_page: z.number().int().positive(),
total: z.number().int().nonnegative(),
total_pages: z.number().int().nonnegative(),
}),
});
}
const PaginatedUsersSchema = paginatedSchema(UserSchema);
type PaginatedUsers = z.infer<typeof PaginatedUsersSchema>;
async function listUsers(page: number): Promise<PaginatedUsers> {
const res = await fetch(`/api/users?page=${page}`);
const json = await res.json();
return PaginatedUsersSchema.parse(json);
}
Write the generic once, reuse it for every paginated endpoint. The itemSchema parameter accepts any Zod schema, so paginatedSchema(PostSchema), paginatedSchema(OrderSchema), etc. all work without repeating the pagination structure.
Handling API Errors with Discriminated Unions
APIs don't always return happy-path data. Model both success and error responses in a single schema:
const ApiResponseSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
data: UserSchema,
}),
z.object({
status: z.literal("error"),
code: z.number(),
message: z.string(),
details: z.array(z.object({
field: z.string(),
issue: z.string(),
})).optional(),
}),
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchUser(id: number) {
const res = await fetch(`/api/users/${id}`);
const json = await res.json();
const parsed = ApiResponseSchema.parse(json);
if (parsed.status === "error") {
// TypeScript narrows: parsed.code, parsed.message are available
throw new Error(`API error ${parsed.code}: ${parsed.message}`);
}
// TypeScript narrows: parsed.data is User
return parsed.data;
}
The discriminated union checks the status field first, then validates only the matching branch. TypeScript narrows the type after the if check — no type assertions needed.
Common Mistakes (and Fixes)
After reviewing dozens of codebases using Zod, these are the patterns that break most often:
Mistake 1: Not handling null vs undefined.
API returns "avatar": null but your schema uses z.string().optional(). That rejects null — optional means "can be missing," not "can be null." Fix: .nullable() for null, .optional() for undefined, .nullish() for both.
Mistake 2: Using .parse() in API routes.
If validation fails, .parse() throws a ZodError which Express/Next.js converts to a generic 500 error. Use .safeParse() and return a proper 400 with field-level errors.
Mistake 3: Validating too late.
Validate at the boundary — right after response.json() or request.body. Not inside your business logic. Not in the component. At the edge of your system, where untrusted data enters.
Mistake 4: Over-strict schemas for external APIs.
If a third-party API adds a new field, a .strict() schema rejects the entire response. Use the default mode (passthrough unknown fields) for external APIs. Only use .strict() for your own API's request validation.
FAQ
How do I validate a deeply nested JSON response without writing a huge schema?
Break it into small, named schemas and compose them. Define AddressSchema, CompanySchema, UserSchema separately, then reference them: z.object({ user: UserSchema, company: CompanySchema }). Each schema stays under 15 lines, and you can test them individually. For the initial scaffolding, paste your JSON into the JSON to Zod converter and refine the output.
Can Zod validate JSON that arrives as a string (like from localStorage)?
Yes. Parse the string first, then validate: const data = JSON.parse(rawString); const result = MySchema.safeParse(data);. If you want to do both in one step, use z.string().transform((s) => JSON.parse(s)).pipe(MySchema). This validates the JSON syntax and the data structure in a single chain.
What happens if my API adds new fields I didn't define in the schema?
By default, Zod passes through unknown fields — your parsed object will include them, but they won't appear in the inferred TypeScript type. This is usually what you want for external APIs. If you want to explicitly remove unknown fields, call .strip() on the schema. If you want validation to fail on unknown fields, call .strict().
How do I share Zod schemas between frontend and backend?
Put schemas in a shared package (e.g., packages/schemas/ in a monorepo) and import them on both sides. Since Zod schemas are plain JavaScript objects, they work identically in Node.js and the browser. The inferred types (z.infer<typeof Schema>) are available at compile time without any runtime overhead on the client. If you need JSON Schema for OpenAPI docs, use zod-to-json-schema to convert.
Next Steps
- Generate Zod schemas from any JSON payload with the JSON to Zod converter — skip writing schemas by hand.
- Need TypeScript types without runtime validation? The JSON to TypeScript converter generates interfaces from raw JSON.
- Read the Zod schema tutorial for a full walkthrough of Zod's API, from primitives to advanced composition patterns.