You've built your RESTWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. endpoints, but there's a problem: users send garbage. Missing fields, wrong types, invalid emails, without validation, this bad data flows straight to your database. ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema. puts a gate at your API entrance, checking every request before it enters your system.
Think of Zod as a bouncer at a club. It checks IDs at the door, making sure everyone meets the requirements before they get in. If someone doesn't qualify, they're turned away with a clear explanation.
Getting started with ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema.
Install the package:
npm install zodImport and create your first schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required.:
import { z } from 'zod';
// Define what a valid user looks like
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0).max(150)
});
// Validate data
const result = UserSchema.parse({
name: "Alice",
email: "alice@example.com",
age: 30
});
// ✅ Returns validated object
UserSchema.parse({
name: "Bob",
email: "not-an-email",
age: 30
});
// ❌ Throws ZodError: "Invalid email"When validation fails, Zod throws a detailed error telling you exactly what went wrong and where.
Building blocks: primitive types
ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema. provides validators for all JavaScript primitives:
// Basic types
const StringSchema = z.string();
const NumberSchema = z.number();
const BooleanSchema = z.boolean();
const DateSchema = z.date();
// Refinements add constraints
const EmailSchema = z.string().email();
const UrlSchema = z.string().url();
const UuidSchema = z.string().uuid();
// String constraints
const PasswordSchema = z.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password too long")
.regex(/[A-Z]/, "Must contain uppercase letter")
.regex(/[0-9]/, "Must contain number");
// Number constraints
const PositiveInt = z.number().int().positive();
const PortSchema = z.number().min(1).max(65535);
const PercentageSchema = z.number().min(0).max(100);.min(), .max(), and .regex(). Default messages like "Invalid input" confuse users. "Password must be at least 8 characters" is actionable.Optional, nullable, and defaults
Real-world data is often incomplete. ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema. handles this gracefully:
// Optional field (undefined allowed)
const OptionalBio = z.string().optional();
// Type: string | undefined
// Nullable field (null allowed)
const NullableAvatar = z.string().nullable();
// Type: string | null
// Default value if field missing
const StatusSchema = z.enum(['active', 'inactive']).default('active');
// Combining them
const UserSchema = z.object({
name: z.string(),
bio: z.string().optional(), // Can be undefined
avatar: z.string().nullable(), // Can be null
status: z.enum(['user', 'admin']).default('user'),
createdAt: z.date().default(() => new Date())
});Object schemas for APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. validation
Most APIs validate objects. Here's a complete user schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. with different variants for different operations:
// Full user schema (for responses)
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
isActive: z.boolean().default(true),
createdAt: z.date(),
updatedAt: z.date()
});
// Schema for creating users (omit id and timestamps)
const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
updatedAt: true
});
// Schema for updates (all fields optional)
const UpdateUserSchema = UserSchema
.omit({ id: true, createdAt: true, updatedAt: true })
.partial();
// Infer TypeScript types automatically
type User = z.infer<typeof UserSchema>;
type CreateUser = z.infer<typeof CreateUserSchema>;
// TypeScript knows these types without manual definition!The .omit() method removes fields, .partial() makes all remaining fields optional. This is perfect for PATCH requests where clients send only the fields they want to change.
Express middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it. for validation
Validation belongs in middleware, not route handlers. Create a reusable validation middleware:
// middleware/validate.js
import { z } from 'zod';
export const validate = (schema) => {
return (req, res, next) => {
try {
// Validate request body against schema
schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
// Format Zod errors for client consumption
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
// Unknown error, pass to error handler
next(error);
}
};
};Use it in your routes:
import { CreateUserSchema, UpdateUserSchema } from './schemas/user.js';
import { validate } from './middleware/validate.js';
// POST requires all mandatory fields
app.post('/users',
validate(CreateUserSchema),
async (req, res) => {
const user = await db.createUser(req.body);
res.status(201).json({
status: 'success',
data: user
});
}
);
// PATCH allows partial updates
app.patch('/users/:id',
validate(UpdateUserSchema),
async (req, res) => {
const user = await db.updateUser(req.params.id, req.body);
res.json({
status: 'success',
data: user
});
}
);Validating params and query strings
Body validation isn't enough. You should also validate URL parameters and query strings:
// Schema for URL parameters
const ParamsSchema = z.object({
id: z.string().regex(/^\d+$/).transform(Number)
});
// Schema for query parameters
const QuerySchema = z.object({
page: z.string()
.optional()
.default('1')
.transform(Number)
.refine(n => n >= 1, "Page must be at least 1"),
limit: z.string()
.optional()
.default('20')
.transform(Number)
.refine(n => n >= 1 && n <= 100, "Limit must be between 1 and 100"),
search: z.string().optional(),
sort: z.enum(['name', 'createdAt', 'updatedAt']).optional()
});
// Enhanced validation middleware
export const validateRequest = (schemas) => {
return (req, res, next) => {
try {
if (schemas.body) {
req.body = schemas.body.parse(req.body);
}
if (schemas.params) {
req.params = schemas.params.parse(req.params);
}
if (schemas.query) {
req.query = schemas.query.parse(req.query);
}
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
next(error);
}
};
};Notice the .transform(Number), it converts string params to numbers automatically. No more parseInt() scattered through your code.
Best practices for APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. validation
1. Validate everything
Never assume client data is safe. Validate:
- Request body (POST, PUT, PATCH)
- URL parameters (IDs, slugs)
- Query strings (paginationWhat is pagination?Splitting a large set of results into smaller pages so the server and client only handle a manageable chunk at a time., filters)
- Headers (content-type, authorizationWhat is authorization?Checking what an authenticated user is allowed to do, like whether they can delete records or access admin pages.)
2. Provide clear error messages
// Bad: generic error
z.string().min(5)
// Error: "Invalid input"
// Good: specific error
z.string().min(5, "Username must be at least 5 characters")
// Error: "Username must be at least 5 characters"3. Transform data when possible
// Transform string to number
const PageSchema = z.string().transform(Number);
// Trim whitespace
const EmailSchema = z.string().trim().email();
// Coerce types
const CoercedNumber = z.coerce.number();
CoercedNumber.parse("42"); // 42 (number)4. Don't leak internal details
// Bad: exposes database structure
if (error.code === '23505') {
return res.status(400).json({
error: 'Unique constraint violation on users.email'
});
}
// Good: user-friendly message
if (error.code === '23505') {
return res.status(409).json({
error: 'This email is already registered'
});
}5. Use TypeScript inference
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email()
});
// TypeScript knows this type automatically!
type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string }Quick reference: common ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema. patterns
| Pattern | Schema | Use case |
|---|---|---|
| String with length | z.string().min(5).max(100) | Names, titles |
z.string().email() | User emails | |
| Positive integer | z.number().int().positive() | IDs, counts |
| Enum | z.enum(['active', 'inactive']) | Status fields |
| Optional | z.string().optional() | Nullable database fields |
| Default | z.string().default('user') | Role defaults |
| Array | z.array(z.string()) | Tags, categories |
Zod transforms validation from a chore into a powerful tool. Define your schemas once, get runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary. validation AND TypeScript types for free. Your future self will thank you when refactoringWhat is refactoring?Restructuring existing code to make it cleaner, more readable, or more efficient without changing what it does. time comes.