Getting Started with Zod Validation
Learn how to use Zod for schema validation in your AI testing workflows.
What Is Zod?
Zod is a TypeScript-first schema validation library.
It lets you:
- Define the shape of data using schemas
- Validate runtime data against those schemas
- Infer TypeScript types automatically from the schema
- Parse and transform input safely
It is commonly used in:
- Node.js backends
- React apps
- API validation layers
- Form validation
- AI/LLM structured output validation
Why Zod Is Popular
- Type inference built in
- No separate validation and type definition
- Clean error formatting
- Works well with React, Next.js, Node
- Ideal for validating AI structured outputs
Getting Started with Zod
Step 1: Install Zod
npm install zod
or
yarn add zod
Step 2: Create Your First Schema
Create a file:
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
This defines a schema where:
namemust be a stringagemust be a number
Step 3: Validate Data
Option A: parse()
Throws an error if validation fails.
const data = {
name: "John",
age: 30,
};
const user = UserSchema.parse(data);
console.log(user);
If invalid:
UserSchema.parse({ name: "John" });
It throws a detailed validation error.
Option B: safeParse()
Does not throw. Returns success status.
const result = UserSchema.safeParse({
name: "John",
age: "30",
});
if (!result.success) {
console.log(result.error.format());
} else {
console.log(result.data);
}
Recommended for APIs and user input.
Step 4: Add Validation Rules
You can add constraints.
const UserSchema = z.object({
name: z.string().min(2).max(50),
age: z.number().int().positive(),
email: z.string().email(),
});
Examples:
.min().max().email().url().uuid().regex()
Step 5: Make Fields Optional or Nullable
const UserSchema = z.object({
name: z.string(),
age: z.number().optional(),
bio: z.string().nullable(),
});
optional()means the field can be undefinednullable()means it can be null
Step 6: Infer TypeScript Types
This is one of Zod’s biggest advantages.
type User = z.infer<typeof UserSchema>;
Now TypeScript automatically generates:
type User = {
name: string;
age?: number;
bio: string | null;
};
No duplicate interface required.
Step 7: Default Values
const UserSchema = z.object({
role: z.string().default("user"),
});
If role is missing, it defaults to "user".
Step 8: Transform Data
You can modify values during parsing.
const UserSchema = z.object({
name: z.string().transform((val) => val.toUpperCase()),
});
Input:
{ "name": "Doe" }
Output:
{ "name": "DOE" }
Step 9: Nested Objects
const AddressSchema = z.object({
city: z.string(),
country: z.string(),
});
const UserSchema = z.object({
name: z.string(),
address: AddressSchema,
});
Step 10: Arrays
const UsersSchema = z.array(UserSchema);
Or:
const UserSchema = z.object({
name: z.string(),
skills: z.array(z.string()),
});
Step 11: Enums
const RoleSchema = z.enum(["admin", "user", "guest"]);
Step 12: Discriminated Unions
Very useful for API responses.
const SuccessResponse = z.object({
status: z.literal("success"),
data: z.string(),
});
const ErrorResponse = z.object({
status: z.literal("error"),
message: z.string(),
});
const ApiResponse = z.discriminatedUnion("status", [
SuccessResponse,
ErrorResponse,
]);
Step 13: Custom Validation
const PasswordSchema = z.string().refine(
(val) => val.length >= 8,
{ message: "Password must be at least 8 characters" }
);
Example: Validating an Express API Request
app.post("/users", (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json(result.error.flatten());
}
const user = result.data;
res.json(user);
});
This prevents:
- Invalid payloads
- Type mismatches
- Unexpected fields