You validate form input with if statements. You parse API responses with type assertions. You trust that JSON.parse returns what you expect. Then production crashes because someone sent "123" instead of 123.
What if your validation automatically generated TypeScript types — and caught every malformed input at runtime?
That's Zod. Define a schema once, get both runtime validation AND TypeScript types.
The Core Concept
import { z } from "zod";
// Define schema
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().max(150),
role: z.enum(["admin", "user", "moderator"]),
tags: z.array(z.string()).max(10).default([]),
});
// Extract TypeScript type — automatically!
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "moderator"; tags: string[] }
// Validate at runtime
const result = UserSchema.safeParse(unknownData);
if (result.success) {
console.log(result.data); // Fully typed User
} else {
console.log(result.error.issues); // Detailed error messages
}
One schema. Both compile-time types and runtime validation. No drift between your TypeScript types and your actual validation logic.
API Input Validation
// Express/Hono/Fastify middleware
app.post("/users", async (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
issues: result.error.issues.map(i => ({
field: i.path.join("."),
message: i.message,
})),
});
}
// result.data is fully typed User — guaranteed valid
const user = await db.users.create(result.data);
return res.json(user);
});
Transform and Coerce
// Transform strings to proper types (common in form data)
const FormSchema = z.object({
price: z.coerce.number(), // "49.99" → 49.99
quantity: z.coerce.number().int().positive(), // "3" → 3
date: z.coerce.date(), // "2026-03-29" → Date object
active: z.coerce.boolean(), // "true" → true
});
// Custom transforms
const SlugSchema = z.string()
.transform(s => s.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
SlugSchema.parse("Hello World!"); // "hello-world"
Composable Schemas
// Base schema
const BaseEntity = z.object({
id: z.string().uuid(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
// Extend it
const Product = BaseEntity.extend({
name: z.string(),
price: z.number().positive(),
category: z.string(),
});
// Pick fields
const ProductPreview = Product.pick({ id: true, name: true, price: true });
// Make fields optional
const UpdateProduct = Product.partial().omit({ id: true, createdAt: true });
// Merge schemas
const ProductWithReviews = Product.merge(z.object({
reviews: z.array(ReviewSchema),
avgRating: z.number().min(0).max(5),
}));
Environment Variable Validation
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
REDIS_URL: z.string().url().optional(),
});
// Validate at app startup — crash early with clear errors
export const env = EnvSchema.parse(process.env);
// env.PORT is number, not string!
Zod + React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const SignupSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Min 8 characters"),
confirmPassword: z.string(),
}).refine(d => d.password === d.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(SignupSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
When to Choose Zod
Choose Zod when:
- You use TypeScript and want type-safe validation
- You validate API inputs, form data, or environment variables
- You want one schema for both types and validation
- Zero dependencies matters (Zod has none)
Skip Zod when:
- You don't use TypeScript (Joi or Yup work for plain JS)
- Performance-critical hot paths (Zod is slower than hand-written validators)
- You only need simple type checks (typeof/instanceof might suffice)
The Bottom Line
Zod turns "I hope this data is correct" into "I know this data is correct." One schema, two guarantees: TypeScript types at compile time, validation at runtime.
Start here: zod.dev
Need custom data extraction, scraping, or automation? I build tools that collect and process data at scale — 78 actors on Apify Store and 265+ open-source repos. Email me: Spinov001@gmail.com | My Apify Actors
Top comments (0)