Stackvora.tech

Express REST API with Validation

Building robust RESTful APIs in Node.js with Express requires careful attention to input validation. Unvalidated data can lead to security…

Building robust RESTful APIs in Node.js with Express requires careful attention to input validation. Unvalidated data can lead to security vulnerabilities, unexpected application behavior, and poor user experience. This article explores a practical approach to integrating schema-based validation using Zod directly within Express route handlers, ensuring data integrity from the moment it hits your API endpoints.

We will cover schema definition, middleware integration, error handling strategies, and how to apply these techniques to various HTTP methods, providing a solid foundation for secure and reliable API development.

Setting Up the Project and Dependencies

Before diving into the code, ensure you have Node.js (v16.x or newer recommended) and npm (or yarn) installed. We'll initialize a new project and add the necessary packages:

mkdir express-zod-api
cd express-zod-api
npm init -y
npm install express zod @types/express --save
npm install typescript ts-node nodemon --save-dev

# Initialize TypeScript config
npx tsc --init

Modify tsconfig.json to include:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}

Create an src directory and an src/index.ts file. Our development workflow will use nodemon for automatic restarts:

// package.json scripts section
"scripts": {
  "start": "node dist/index.js",
  "dev": "nodemon --exec ts-node src/index.ts",
  "build": "tsc"
}

Defining Validation Schemas with Zod

Zod is a TypeScript-first schema declaration and validation library. It allows you to define complex data structures and validate incoming data against them. We'll place our schemas in a dedicated file, e.g., src/schemas/user.ts.

// src/schemas/user.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string()
    .min(3, { message: "Name must be at least 3 characters long." })
    .max(50, { message: "Name cannot exceed 50 characters." }),
  email: z.string()
    .email({ message: "Invalid email address." })
    .max(100, { message: "Email cannot exceed 100 characters." }),
  password: z.string()
    .min(8, { message: "Password must be at least 8 characters long." })
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+])[A-Za-z\d!@#$%^&*()_+]{8,}$/, {
      message: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
    }),
  role: z.enum(["admin", "user", "guest"]).default("user"),
});

export const updateUserSchema = z.object({
  name: z.string()
    .min(3, { message: "Name must be at least 3 characters long." })
    .max(50, { message: "Name cannot exceed 50 characters." })
    .optional(),
  email: z.string()
    .email({ message: "Invalid email address." })
    .max(100, { message: "Email cannot exceed 100 characters." })
    .optional(),
  password: z.string()
    .min(8, { message: "Password must be at least 8 characters long." })
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+])[A-Za-z\d!@#$%^&*()_+]{8,}$/, {
      message: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
    })
    .optional(),
  isActive: z.boolean().optional(),
}).refine(data => Object.keys(data).length > 0, {
  message: "At least one field must be provided for update.",
});

export const userIdParamSchema = z.object({
  id: z.string().uuid({ message: "Invalid user ID format. Must be a UUID." }),
});

// Infer TypeScript types from Zod schemas
export type CreateUserPayload = z.infer;
export type UpdateUserPayload = z.infer;
export type UserIdParam = z.infer;

In this example:

  • createUserSchema defines all required fields for user creation, including complex regex for password validation.
  • updateUserSchema uses .optional() for fields that might not be present in a partial update. The .refine() method adds custom validation to ensure at least one field is provided.
  • userIdParamSchema is used for validating URL parameters, specifically ensuring an ID is a valid UUID.
  • z.infer is crucial for deriving TypeScript types from our Zod schemas, maintaining type safety throughout the application.

Implementing Validation Middleware

To keep route handlers clean, we'll create a generic validation middleware. This middleware will accept a Zod schema and validate the request body, query parameters, or route parameters against it.

// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { AnyZodObject, ZodError } from "zod";

export const validate = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      // Prioritize request body validation
      await schema.parseAsync(req.body);
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        // ZodError.flatten() provides a more readable error structure
        return res.status(400).json({
          message: "Validation error",
          errors: error.flatten().fieldErrors, // Only field-specific errors
        });
      }
      // Catch any other unexpected errors
      return res.status(500).json({ message: "Internal server error during validation." });
    }
  };

// A more flexible validation middleware for different request parts
export const validateRequest = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { body, query, params } = req;
      // You can compose schemas here or pass a schema designed for specific parts
      // For simplicity, this example assumes the schema validates against req.body directly
      // Or you can create an "overall" schema like:
      // z.object({
      //   body: bodySchema.optional(),
      //   query: querySchema.optional(),
      //   params: paramsSchema.optional(),
      // }).parseAsync({ body, query, params });

      // For direct body/query/params validation based on context
      // We'll use a more targeted approach in the routes.

      next(); // If the schema was designed for a specific part, it would be validated before this line.
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          message: "Validation error",
          errors: error.flatten().fieldErrors,
        });
      }
      next(error); // Pass other errors to the Express error handler
    }
  };

// Specific validation for request body
export const validateBody = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = await schema.parseAsync(req.body); // Cast/transform and reassign validated body
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          message: "Validation error for request body",
          errors: error.flatten().fieldErrors,
        });
      }
      next(error);
    }
  };

// Specific validation for request parameters (e.g., /users/:id)
export const validateParams = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      req.params = await schema.parseAsync(req.params); // Cast/transform and reassign validated params
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          message: "Validation error for request parameters",
          errors: error.flatten().fieldErrors,
        });
      }
      next(error);
    }
  };

// Specific validation for request query string
export const validateQuery = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      req.query = await schema.parseAsync(req.query); // Cast/transform and reassign validated query
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          message: "Validation error for request query",
          errors: error.flatten().fieldErrors,
        });
      }
      next(error);
    }
  };

We've created specialized middleware functions (validateBody, validateParams, validateQuery) to target specific parts of the request. This approach provides more granular control and clearer error messages. The parseAsync method is used to handle potential async refinements in schemas.

Integrating Validation into Express Routes

Now, let's wire up our schemas and middleware into the main Express application.

// src/index.ts
import express from "express";
import { z } from "zod";
import { createUserSchema, updateUserSchema, userIdParamSchema, CreateUserPayload, UpdateUserPayload, UserIdParam } from "./schemas/user";
import { validateBody, validateParams } from "./middleware/validate";

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json()); // Middleware to parse JSON request bodies

// --- Routes with Zod Validation ---

// POST /users - Create a new user
app.post(
  "/users",
  validateBody(createUserSchema),
  (req, res) => {
    // req.body is now guaranteed to conform to CreateUserPayload type
    const newUser: CreateUserPayload = req.body;
    console.log("Creating user:", newUser);
    // In a real application, you would save newUser to a database
    // Assign a mock ID for demonstration
    const createdUser = { id: `user-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, ...newUser };
    res.status(201).json({ message: "User created successfully", user: createdUser });
  }
);

// GET /users/:id - Retrieve a user by ID
app.get(
  "/users/:id",
  validateParams(userIdParamSchema),
  (req, res) => {
    // req.params.id is now guaranteed to be a valid UUID string
    const { id } = req.params as UserIdParam; // Type assertion after validation
    console.log("Fetching user with ID:", id);
    // In a real application, fetch user from DB using 'id'
    const mockUser = {
      id: id,
      name: "John Doe",
      email: "john.doe@example.com",
      role: "user",
      isActive: true,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    res.status(200).json(mockUser);
  }
);

// PUT /users/:id - Update an existing user
app.put(
  "/users/:id",
  validateParams(userIdParamSchema),
  validateBody(updateUserSchema),
  (req, res) => {
    const { id } = req.params as UserIdParam;
    const updateData: UpdateUserPayload = req.body;
    console.log(`Updating user ${id} with data:`, updateData);
    // In a real application, find and update user in DB
    const updatedUser = {
      id: id,
      name: updateData.name || "Jane Doe",
      email: updateData.email || "jane.doe@example.com",
      isActive: updateData.isActive ?? true,
      role: "admin", // Example: role might be set by logic, not directly from payload
      updatedAt: new Date().toISOString(),
    };
    res.status(200).json({ message: "User updated successfully", user: updatedUser });
  }
);

// Basic GET route
app.get("/", (req, res) => {
  res.send("Express API with Zod validation is running!");
});

// Global Error Handler (optional, but good practice for unhandled errors)
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
  console.log(`Access at http://localhost:${PORT}`);
});

Notice how validateBody and validateParams are directly chained as middleware before the route handler. If validation fails, the middleware immediately sends a 400 response with detailed error messages. If validation passes, req.body and req.params are guaranteed to conform to the Zod schema's inferred type, allowing us to confidently use them in the handler without further type checking or casting (though explicit casting like req.params as UserIdParam can sometimes help with TypeScript inference when using generic middleware).

Testing the API Endpoints

Let's use curl or a tool like Postman/Insomnia to test the validation.

Successful User Creation

curl -X POST http://localhost:3000/users \
     -H "Content-Type: application/json" \
     -d '{
           "name": "Alice Wonderland",
           "email": "alice@example.com",
           "password": "Password123!",
           "role": "user"
         }'

Expected Output (Status 201 Created):

{"message":"User created successfully","user":{"id":"user-...", "name":"Alice Wonderland", "email":"alice@example.com", "password":"Password123!", "role":"user"}}

Invalid User Creation (Missing fields, invalid email, weak password)

curl -X POST http://localhost:3000/users \
     -H "Content-Type: application/json" \
     -d '{
           "name": "Al",
           "email": "invalid-email",
           "password": "pass"
         }'

Expected Output (Status 400 Bad Request):

{
  "message": "Validation error for request body",
  "errors": {
    "name": [ "Name must be at least 3 characters long." ],
    "email": [ "Invalid email address." ],
    "password": [ "Password must be at least 8 characters long.", "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character." ]
  }
}

Updating a User (Partial update)

# Assuming a UUID like 550e8400-e29b-41d4-a716-446655440000
curl -X PUT http://localhost:3000/users/550e8400-e29b-41d4-a716-446655440000 \
     -H "Content-Type: application/json" \
     -d '{
           "name": "Alice Updated",
           "isActive": false
         }'

Expected Output (Status 200 OK):

{"message":"User updated successfully","user":{"id":"550e8400-e29b-41d4-a716-446655440000", "name":"Alice Updated", "email":"jane.doe@example.com", "isActive":false, "role":"admin", ...}}

Invalid User ID for GET/PUT

curl -X GET http://localhost:3000/users/invalid-uuid

Expected Output (Status 400 Bad Request):

{
  "message": "Validation error for request parameters",
  "errors": {
    "id": [ "Invalid user ID format. Must be a UUID." ]
  }
}

Common Pitfalls and Considerations

  • Middleware Order: Ensure express.json() (or express.urlencoded()) is used *before* your validation middleware, otherwise req.body will be undefined.
  • Error Handling Granularity: While ZodError.flatten().fieldErrors is often sufficient, Zod's .format() method offers even more detailed error structures, useful for complex UIs.
  • Performance: For extremely high-throughput APIs, consider the overhead of parsing and validating every request. Zod is generally very fast, but if you have deeply nested, massive schemas, it's worth profiling. For most applications, the overhead is negligible compared to the benefits of robust validation.
  • Async Validation: Zod supports asynchronous validations (e.g., checking if an email already exists in the database). Remember to use .parseAsync() in your middleware and ensure your route handler is async if using them.
  • Request Part Separation: Clearly define which part of the request (body, query, params) each schema validates. Our tailored validateBody, validateParams, and validateQuery functions help enforce this.
  • Type Safety with Express: Express's Request and Response types from @types/express are generic. While z.infer provides types for your data, integrating them perfectly with req.body or req.params in generic middleware often requires careful type assertions or custom type declarations for your specific Request object, or as shown, simply asserting within the route handler after validation.

Back to the knowledge base · Ask the AI assistant