zodtypescriptvalidationapi

JSON to Zod Schema: Runtime Validation for TypeScript APIs

·8 min read

What Is Zod and Why Does It Matter?

Zod is a TypeScript-first schema validation library with over 20 million weekly npm downloads. It solves one of the most frustrating problems in TypeScript: compile-time types give you no guarantee that runtime data actually matches them.

When you call fetch("/api/users") and cast the result as User[], TypeScript believes you — but the runtime might receive a completely different shape. Zod validates the shape at runtime and throws a detailed, actionable error if it does not match:

typescript
import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  email: z.string().email(),
  active: z.boolean(),
});

type User = z.infer<typeof UserSchema>;
// type User = { id: number; name: string; email: string; active: boolean }
// You get the TypeScript type automatically from the schema — no duplication

// At runtime:
const raw = await fetch("/api/users/1").then(r => r.json());
const user = UserSchema.parse(raw);
// user is now fully typed AND runtime-validated

Generating a Zod Schema from JSON

Instead of writing schemas manually, paste your API response JSON and get the schema auto-generated:

Input JSON:

json
{
  "id": 1,
  "name": "Ravi Kumar",
  "active": true,
  "score": 98.5,
  "address": { "city": "Surat", "country": "IN" },
  "tags": ["developer", "typescript"],
  "createdAt": "2025-06-01T10:30:00Z"
}

Generated Zod schema:

typescript
import { z } from "zod";

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

export const UserSchema = z.object({
  id: z.number().int(),
  name: z.string(),
  active: z.boolean(),
  score: z.number(),
  address: AddressSchema,
  tags: z.array(z.string()),
  createdAt: z.string(),
});

export type User = z.infer<typeof UserSchema>;
export type Address = z.infer<typeof AddressSchema>;

JSON to Zod Type Mapping

JSON typeZod validatorTypeScript type
stringz.string()string
integer numberz.number().int()number
float numberz.number()number
booleanz.boolean()boolean
nullz.null()null
objectz.object({...})Named interface
array of stringsz.array(z.string())string[]
empty arrayz.array(z.unknown())unknown[]
mixed typesz.union([...])Union type

Adding Real-World Constraints

The generator creates a baseline schema. Add refinements for your actual business rules:

typescript
const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1, "Name cannot be empty").max(100, "Name too long"),
  email: z.string().email("Invalid email address"),
  age: z.number().int().min(0).max(150).optional(),
  url: z.string().url().optional(),
  role: z.enum(["admin", "editor", "viewer"]),
  tags: z.array(z.string()).min(1, "At least one tag required"),
  score: z.number().min(0).max(100),
  phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number").optional(),
});

Optional vs Nullable vs Nullish

typescript
const schema = z.object({
  name: z.string(),                         // required, cannot be null or undefined
  middleName: z.string().optional(),        // may be absent (undefined); field can be omitted
  bio: z.string().nullable(),               // present but may be null
  avatar: z.string().nullish(),             // may be null or undefined (both)
  count: z.number().default(0),             // optional with default value
});

// Type inference:
// name: string
// middleName: string | undefined
// bio: string | null
// avatar: string | null | undefined
// count: number

parse() vs safeParse()

typescript
// parse() — throws ZodError with detailed issues if validation fails
try {
  const user = UserSchema.parse(rawData);
  // user is fully typed and validated
} catch (e) {
  if (e instanceof z.ZodError) {
    console.error("Validation failed:", e.issues);
    // Each issue: { path: ["email"], message: "Invalid email", code: "invalid_string" }
  }
}

// safeParse() — never throws; returns a discriminated union
const result = UserSchema.safeParse(rawData);
if (!result.success) {
  console.error("Errors:", result.error.issues);
  return null;
}
const user = result.data; // typed as User

Use safeParse for API handlers and form validation. Use parse in data pipelines where you want to throw on invalid data.

Integration with Next.js API Route (App Router)

typescript
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

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

export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => null);

  const result = CreateUserSchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json(
      { errors: result.error.issues.map(i => ({ field: i.path.join("."), message: i.message })) },
      { status: 400 }
    );
  }

  const { name, email, role } = result.data; // fully typed
  // ... create user
  return NextResponse.json({ created: true });
}

Integration with React Hook Form

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

const formSchema = z.object({
  name:  z.string().min(1, "Name is required"),
  email: z.string().email("Please enter a valid email"),
  age:   z.number().int().min(18, "Must be 18 or older"),
});

type FormData = z.infer<typeof formSchema>;

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(formSchema),
  });

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}
      {/* ... */}
    </form>
  );
}

Validating Environment Variables

typescript
// src/env.ts — validate at startup, not silently at runtime
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL:  z.string().url(),
  JWT_SECRET:    z.string().min(32, "JWT secret must be at least 32 chars"),
  PORT:          z.coerce.number().int().min(1).max(65535).default(3000),
  NODE_ENV:      z.enum(["development", "test", "production"]),
});

export const env = envSchema.parse(process.env);
// Throws with clear messages on startup if any env var is missing or wrong
// e.g., "Invalid url" for DATABASE_URL, not a cryptic runtime error later

Zod vs TypeScript Interfaces vs io-ts

FeatureTypeScript interfacesZodio-ts
Compile-time typesYesYes (via z.infer)Yes
Runtime validationNoYesYes
Error messagesN/ADetailed, structuredVerbose
Learning curveLowLowHigh (fp-ts)
Bundle sizeZero (erased)~13KB gzip~12KB + fp-ts
Transform/coerceNoYes (z.coerce, .transform)Limited

Use JSONKit's JSON to Zod Schema tool to generate your baseline schema from any JSON response — then add constraints and refinements for your business rules.

Try JSON to Zod Schema

Generate Zod validation schemas with TypeScript type export from any JSON.