The most surprising thing about validating Express request input is that you’re not just checking data types; you’re defining the contract for how your API should be used, and the validation library is your enforcer.

Imagine you have an Express route that accepts a POST request to /users. You want to create a new user, and the request body should look something like this:

{
  "name": "Alice Smith",
  "email": "alice.smith@example.com",
  "age": 30
}

Without validation, your route handler might receive malformed data, missing fields, or data that doesn’t conform to expectations, leading to runtime errors or unexpected behavior. This is where schema validation libraries like Joi or Zod come in. They allow you to define the expected structure and types of your incoming request data.

Let’s see how this looks with Zod, a popular choice for its TypeScript integration and performance.

First, install Zod:

npm install zod

Then, in your Express route file, define your schema:

// zod-user-schema.js
import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(1, { message: "Name cannot be empty" }),
  email: z.string().email({ message: "Invalid email address" }),
  age: z.number().int().positive({ message: "Age must be a positive integer" }).optional(),
});

Now, integrate this schema into your Express route. You’ll typically do this using middleware.

// userRoutes.js
import express from 'express';
import { createUserSchema } from './zod-user-schema.js';

const router = express.Router();

// Middleware to validate request body
const validateRequestBody = (schema) => (req, res, next) => {
  try {
    schema.parse(req.body); // Zod validates the data
    next(); // If valid, proceed to the route handler
  } catch (error) {
    // If validation fails, Zod throws an error
    if (error instanceof z.ZodError) {
      // Extract specific error messages
      const errors = error.errors.map(err => ({
        path: err.path.join('.'),
        message: err.message
      }));
      return res.status(400).json({ message: "Validation failed", errors });
    }
    // Handle other potential errors
    next(error);
  }
};

router.post('/users', validateRequestBody(createUserSchema), (req, res) => {
  // If we reach here, req.body has been validated by Zod
  const userData = req.body;
  console.log('Received valid user data:', userData);
  // Proceed with creating the user in your database
  res.status(201).json({ message: "User created successfully", user: userData });
});

export default router;

When a client sends a request to /users, the validateRequestBody middleware intercepts it. createUserSchema.parse(req.body) attempts to match the incoming req.body against the defined schema. If it conforms, next() is called, and the request reaches your route handler. If it doesn’t, Zod throws a ZodError, which is caught, and a 400 Bad Request response is sent back with detailed error information.

Consider these requests:

Valid Request:

POST /users
Content-Type: application/json

{
  "name": "Bob Johnson",
  "email": "bob@example.com",
  "age": 25
}

Response:

{
  "message": "User created successfully",
  "user": {
    "name": "Bob Johnson",
    "email": "bob@example.com",
    "age": 25
  }
}

Invalid Request (missing name, invalid email):

POST /users
Content-Type: application/json

{
  "email": "not-an-email",
  "age": "thirty"
}

Response:

{
  "message": "Validation failed",
  "errors": [
    {
      "path": "name",
      "message": "Required"
    },
    {
      "path": "email",
      "message": "Invalid email address"
    },
    {
      "path": "age",
      "message": "Expected number, received string"
    }
  ]
}

The schema definition (createUserSchema) is the single source of truth for what constitutes valid user data. This provides a clear, machine-readable contract for your API. The z.object(), z.string(), z.number(), .email(), .int(), .positive(), and .optional() methods are the building blocks you use to construct these contracts, specifying required fields, data types, format constraints, and optionality.

The most powerful aspect is how this declarative schema definition directly translates into executable validation logic. You define what is valid, and the library handles the complex task of checking if the incoming data adheres to that definition. This drastically reduces boilerplate code for input validation and makes your API more robust and predictable.

A common mistake is to forget that Zod (and Joi) can also validate query parameters (req.query) and URL parameters (req.params) in addition to the request body. You can define separate schemas for each part of the request, ensuring the entire incoming request adheres to your API’s contract, not just the payload.

Want structured learning?

Take the full Express course →