Validate API Input With Zod
Robust API design necessitates stringent input validation to prevent malformed data from corrupting application state, triggering security…
Robust API design necessitates stringent input validation to prevent malformed data from corrupting application state, triggering security vulnerabilities, or causing unexpected behavior. While manual validation is error-prone and verbose, schema validation libraries offer a declarative, maintainable solution. Zod, a TypeScript-first schema declaration and validation library, provides powerful capabilities for defining data structures and ensuring incoming API payloads conform to expected types and constraints.
This article explores implementing Zod for comprehensive API input validation in Node.js environments, focusing on defining schemas, integrating with common web frameworks, and handling validation errors gracefully. We'll demonstrate how Zod's strong typing enhances developer experience and maintains a single source of truth for data shapes.
Defining Validation Schemas with Zod
Zod schemas are built using a fluent API, allowing developers to define complex data structures by chaining methods. The core building blocks are primitive types and combinators for objects, arrays, unions, and more. All schemas are immutable; each method call returns a new schema instance.
Basic Primitives and Constraints
Zod provides direct mappings for common JavaScript types, along with methods to apply validation rules:
z.string(): For string types. Can be chained with.min(length),.max(length),.length(length),.email(),.url(),.uuid(),.regex(pattern),.startsWith(prefix),.endsWith(suffix).z.number(): For numeric types. Includes.min(value),.max(value),.positive(),.negative(),.int(),.finite().z.boolean(): For boolean types.z.date(): For Date objects.z.undefined(),z.null(): For explicit undefined or null values.z.any(),z.unknown(): For less strict scenarios.
Object Schemas
The z.object() method is central for defining the structure of request bodies or query parameters. It takes an object where keys are the expected fields and values are their corresponding Zod schemas.
import { z } from 'zod';
// Example: User registration body
const UserRegistrationBodySchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters long.").max(20, "Username cannot exceed 20 characters."),
email: z.string().email("Invalid email address format."),
password: z.string().min(8, "Password must be at least 8 characters long.")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter.")
.regex(/[a-z]/, "Password must contain at least one lowercase letter.")
.regex(/[0-9]/, "Password must contain at least one number.")
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character."),
age: z.number().int("Age must be an integer.").min(18, "You must be at least 18 years old."),
newsletterOptIn: z.boolean().default(false), // Optional, defaults to false if not provided
role: z.enum(["admin", "editor", "viewer"]).optional() // Optional field with allowed values
});
// Example: Query parameters for pagination
const PaginationQueryParamsSchema = z.object({
page: z.string().transform(Number).pipe(z.number().int().min(1, "Page number must be at least 1.")).default("1"),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100, "Limit cannot exceed 100.")).default("10"),
sortBy: z.enum(["createdAt", "updatedAt", "name"]).optional()
});
Notice the .transform(Number).pipe(z.number()) pattern for query parameters. HTTP query parameters are always strings, so we transform them to numbers before applying numeric Zod validations. .pipe() chains schemas, passing the output of the first as input to the second.
Arrays, Unions, and Literals
z.array(elementSchema): Defines an array of a specific type. E.g.,z.array(z.string().uuid())for an array of UUIDs.z.union([schema1, schema2]): Allows a value to match any of the provided schemas. E.g.,z.union([z.string(), z.number()]).z.literal(value): Matches an exact, specific value. E.g.,z.literal("pending").z.enum([...values]): A shorthand for a union of string literals.
Integrating Zod with Node.js Web Frameworks
Zod validation typically occurs as a middleware or within the route handler itself, "at the edge" of your API request processing. The goal is to catch invalid input as early as possible.
Express.js Integration Example
For Express.js, a common pattern is to create a validation middleware function.
import { z, ZodError } from 'zod';
import express, { Request, Response, NextFunction } from 'express';
// Re-using the schema from above
const UserRegistrationBodySchema = z.object({
username: z.string().min(3),
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18),
});
// TypeScript type inference from schema
type UserRegistrationInput = z.infer;
// Middleware for validating request body
const validate = (schema: z.AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
// Parse the request body against the schema
// .parse() throws a ZodError if validation fails
// .safeParse() returns a result object (success: boolean, data/error)
req.body = await schema.parseAsync(req.body); // Use parseAsync for async refinements if any
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation failed",
errors: error.issues.map(issue => ({
path: issue.path.join('.'), // 'body.username'
message: issue.message,
code: issue.code // e.g., 'invalid_string', 'too_small'
}))
});
}
// Handle other potential errors
next(error);
}
};
const app = express();
app.use(express.json()); // Essential for parsing JSON request bodies
app.post('/register', validate(UserRegistrationBodySchema), (req: Request, res: Response) => {
// If we reach here, req.body is guaranteed to conform to UserRegistrationBodySchema
const userData: UserRegistrationInput = req.body; // Fully typed!
console.log("Validated user data:", userData);
res.status(201).json({ message: "User registered successfully", user: userData });
});
// Example route with query parameter validation
app.get('/products', validate(PaginationQueryParamsSchema), (req: Request, res: Response) => {
const queryParams = req.query as z.infer;
console.log("Query parameters:", queryParams);
res.json({ message: "Products retrieved", page: queryParams.page, limit: queryParams.limit });
});
app.listen(3000, () => console.log('Server running on port 3000'));
In this example, validate is a higher-order function that takes a Zod schema and returns an Express middleware. Inside the middleware, schema.parseAsync(req.body) attempts to validate and transform the input. If it fails, a ZodError is caught, and a 400 Bad Request response is sent with structured error messages. If successful, req.body is updated with the parsed and validated data, and the request proceeds to the next middleware or route handler.
Deriving TypeScript Types with z.infer
One of Zod's most compelling features is its tight integration with TypeScript. You can derive static TypeScript types directly from your Zod schemas using z.infer<typeof YourSchema>. This ensures that your runtime validation logic and your compile-time types are always in sync, providing a single source of truth for your data structures.
import { z } from 'zod';
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
description: z.string().optional(),
tags: z.array(z.string()).default([])
});
// Derive the TypeScript type for a Product
type Product = z.infer;
const product1: Product = {
id: "a1b2c3d4-e5f6-7890-1234-567890abcdef",
name: "Wireless Mouse",
price: 29.99,
tags: ["electronics", "peripherals"]
};
// This would result in a TypeScript error because 'price' cannot be negative
// const product2: Product = { id: "...", name: "...", price: -5 };
// This object can be passed to ProductSchema.parse() and will be validated
const rawProductData = {
id: "b2c3d4e5-f6a7-8901-2345-67890abcdef1",
name: "Mechanical Keyboard",
price: 120.00
};
const validatedProduct = ProductSchema.parse(rawProductData);
console.log(validatedProduct); // { id: "...", name: "...", price: 120, tags: [] } - tags defaulted
This approach eliminates the need to declare interfaces or types separately, reducing boilerplate and potential inconsistencies between your validation logic and type definitions.
Advanced Zod Features and Refinements
Beyond basic types, Zod offers powerful features for more complex validation scenarios.
Custom Error Messages
You can provide custom error messages for almost all validation rules, as seen in the UserRegistrationBodySchema example. This allows for more user-friendly feedback.
Refinements
For validations that cannot be expressed with built-in methods, Zod's .refine() and .superRefine() methods allow you to define custom validation logic. .refine() takes a predicate function and an error message (or an object with message and path). .superRefine() offers more granular control, allowing multiple errors to be added.
const PasswordSchema = z.string().refine(
(val) => val === val.trim(),
"Password cannot start or end with whitespace."
).refine(
(val) => val.includes("!"),
{
message: "Password must contain at least one exclamation mark.",
path: ["password_complexity"] // Custom path for error
}
);
const UserSchemaWithComplexValidation = z.object({
password: PasswordSchema,
confirmPassword: z.string()
}).superRefine(({ password, confirmPassword }, ctx) => {
if (password !== confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match.",
path: ["confirmPassword"]
});
}
});
Coercion and Transformations
Zod can also be used for data transformation using .transform() or .preprocess(). For instance, converting string-based query parameters or form fields into numbers or booleans before validation.
const CoercionSchema = z.object({
itemsPerPage: z.string().default("20").transform(val => parseInt(val, 10)).pipe(z.number().int().min(1)),
isActive: z.string().transform(val => val === "true").pipe(z.boolean())
});
.preprocess() is useful when you need to modify the input *before* Zod attempts to parse its type (e.g., handling empty strings for optional numbers).
Common Pitfalls and Troubleshooting
undefinedvs..optional()vs..nullable(): Be precise.z.string().optional()means the field can be a string orundefined.z.string().nullable()means it can be a string ornull.z.string().optional().nullable()allows string,undefined, ornull.- Asynchronous Refinements: If your
.refine()or.superRefine()involves an asynchronous operation (e.g., database lookup for unique username), remember to use.parseAsync()or.safeParseAsync()instead of their synchronous counterparts, and await the result. - Error Handling: Always wrap
.parse()calls in atry...catchblock to handleZodError. Provide meaningful error messages and mapZodError.issuesto a consistent API response format. - Transforming Input: Remember that request bodies (especially JSON) are already parsed by middleware like
express.json(). Query parameters, however, are strings and often require transformation (e.g.,.transform(Number)) before numeric/boolean validation. - Schema Complexity: For very large or nested schemas, consider breaking them down into smaller, reusable schemas. This improves readability and maintainability.
- Performance: For extremely high-throughput APIs, consider the overhead of extensive validations. While Zod is generally fast, very complex regexes or numerous custom refinements can add latency. Optimize when necessary, but prioritize correctness.