From signup JSON to Zod schema
With Signup as the Root name, the sample signup form body becomes:
{
"email": "sam@example.com",
"password": "correct-horse",
"displayName": "Sam",
"age": 29,
"acceptTerms": true,
"interests": ["hiking", "jazz"],
"referral": { "code": "WELCOME10", "source": "newsletter" }
}
import { z } from "zod";
export const ReferralSchema = z.object({
code: z.string(),
source: z.string(),
});
export type Referral = z.infer<typeof ReferralSchema>;
export const SignupSchema = z.object({
email: z.string(),
password: z.string(),
displayName: z.string(),
age: z.number().int(),
acceptTerms: z.boolean(),
interests: z.array(z.string()),
referral: ReferralSchema,
});
export type Signup = z.infer<typeof SignupSchema>;
ReferralSchemacomes first because aconstcan’t be used before the line that defines it.ageheld a whole number, so it got.int(); one decimal value would have made it plainz.number().- Each schema is followed by its
z.infertype, so the TypeScript type can’t drift from the validator.
Download saves this as signup.ts, the file imported below.
Null and missing values
Array items are merged before anything is written (rules on JSON to TypeScript), and the result turns into method calls. With Plans as the root name:
[
{ "plan": "pro", "seats": 5, "coupon": "SPRING" },
{ "plan": "free", "seats": 1, "coupon": null },
{ "plan": "team", "seats": 12 }
]
import { z } from "zod";
export const PlanSchema = z.object({
plan: z.string(),
seats: z.number().int(),
coupon: z.string().nullable().optional(),
});
export type Plan = z.infer<typeof PlanSchema>;
export const PlansSchema = z.array(PlanSchema);
export type Plans = z.infer<typeof PlansSchema>;
coupon was a string, then null, then missing, so it ends in .nullable().optional() and infers as string | null | undefined. An array root also gets its own schema, PlansSchema.
Validate with parse or safeParse
parse returns typed data or throws a ZodError; safeParse returns { success: true, data } or { success: false, error }:
import { SignupSchema } from "./signup";
export async function POST(req: Request) {
const result = SignupSchema.safeParse(await req.json());
if (!result.success) {
return Response.json({ issues: result.error.issues }, { status: 400 });
}
const signup = result.data; // typed as Signup
return Response.json({ welcome: signup.displayName });
}
Each issue has a path, such as ["referral", "source"], and a message. For per-field form errors, call z.flattenError(result.error) in Zod 4 or result.error.flatten() in Zod 3. Unknown keys are dropped from result.data; .strict() rejects them instead.
Rules a sample can’t show
A sample gives types, not constraints, so email and password are plain z.string(). Add what you know with .extend():
import { z } from "zod";
import { SignupSchema } from "./signup";
export const StrictSignupSchema = SignupSchema.extend({
email: z.email(),
password: z.string().min(8),
age: z.coerce.number().int(),
});
z.email() is Zod 4 (z.string().email() in Zod 3), and z.coerce.number() turns a form’s "29" into 29. Zod 4’s .int() only accepts safe integers, up to 9,007,199,254,740,991, so larger 64-bit IDs sent as numbers fail; JSON.parse loses their digits anyway, so send them as strings.
For a contract other languages can read, use the JSON Schema generator and check data against it with the JSON Schema validator.