We have repeated validation code in our routes. Looking at our route files, the same validation patterns appear multiple times:

// This appears in GET /:id, PUT /:id, PATCH /:id, DELETE /:id
const userId = Number(req.params.id);
if (isNaN(userId)) {
  return res.status(400).json({ error: "Invalid user ID" });
}

// This appears in POST /, PUT /:id
if (!username || !email) {
  return res.status(400).json({ error: "Username and email are required" });
}

Instead of copying this code everywhere, let’s extract it into reusable middleware functions.

Creating the Middleware Folder

We could use a simple middleware.ts file in the root folder, but we’ll use a middleware/ folder to prepare for the next lesson, where we’ll add more validation files.

We’ll create three different validation functions to handle the different validation requirements across our API:

  • validateUserId: Validates URL parameters (checks if the ID is a valid number).
  • validateRequiredUserData: Validates that ALL required fields are present (used for POST and PUT requests that need complete data).
  • validatePartialUserData: Validates that AT LEAST ONE field is present (used for PATCH requests that allow partial updates).

Create middleware/validation.ts:

import { Request, Response, NextFunction } from "express";

export function validateUserId(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const userId = Number(req.params.id);
  if (isNaN(userId)) {
    return res.status(400).json({ error: "Invalid user ID" });
  }
  next();
}

export function validateRequiredUserData(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const { username, email } = req.body;
  if (!username || !email) {
    return res.status(400).json({ error: "Username and email are required" });
  }
  next();
}

export function validatePartialUserData(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const { username, email } = req.body;
  if (!username && !email) {
    return res
      .status(400)
      .json({ error: "At least one of username or email is required" });
  }
  next();
}

Why We Need Explicit Types Here

Notice that we import Request, Response, and NextFunction from Express and explicitly type each parameter. This is different from our route handlers, where TypeScript could infer the types.

When you define middleware functions separately like this, TypeScript cannot determine what types the parameters should have because it doesn’t know they’ll be used with Express. In contrast, when you write inline route handlers like router.get("/", (req, res) => {...}), TypeScript knows from the context that these are Express request and response objects.

We covered middleware in Module 1, Lesson 3, but here’s what’s happening in these functions:

  • Import Express types: We import Request, Response, and NextFunction from Express for TypeScript typing.
  • Standard middleware signature: Each function takes req, res, and next parameters with explicit type annotations.
  • Call next(): If validation passes, continue to the next middleware or route handler.
  • Return early: If validation fails, send an error response and don’t call next().

Using Middleware in Route Files

Now we’ll update routes/users.ts to use the middleware functions we created. Remember from Module 1 that middleware functions are passed as arguments to route methods, positioned between the route path and the main route handler.

Importing Middleware Functions

First, import the validation functions at the top of your routes/users.ts file:

import {
  validateUserId,
  validateRequiredUserData,
  validatePartialUserData,
} from "../middleware/validation";

Routes Without Middleware

Some routes don’t require validation middleware:

router.get("/", async (req, res) => {
  // Pagination logic for getting all users
});

Routes With One Middleware

Routes that need to validate the ID use a single middleware function between the path and handler.

Notice how we no longer need the isNaN() validation check in the handler because the middleware already validates the ID:

router.get("/:id", validateUserId, async (req, res) => {
  const userId = Number(req.params.id);
  const [rows] = ...
  // Rest of handler logic here
});

Routes With Multiple Middleware

Routes that need to validate the ID and user data can chain middleware functions in the order they should execute.

Notice how the handler no longer needs isNaN() checks or if (!username || !email) validation because validateUserId validates the ID and validateRequiredUserData validates the request body:

router.put("/:id", validateUserId, validateRequiredUserData, async (req, res) => {
  const userId = Number(req.params.id);
  const { username, email } = req.body;
  const [result]: [ResultSetHeader, any] = ...
  // Rest of handler logic here
});

Our patch route similarly uses two middleware functions.

router.patch(
  "/:id",
  validateUserId,
  validatePartialUserData,
  async (req, res) => {
    const userId = Number(req.params.id);
    const { username, email } = req.body;
    const fieldsToUpdate = [];
    // Rest of handler logic here
  }
);

Routes With Data Validation Only

POST routes typically only need data validation since they don’t use URL parameters:

router.post("/", validateRequiredUserData, async (req, res) => {
  const { username, email } = req.body;
  const [result]: [ResultSetHeader, any] = ...
   // Rest of handler logic here
});

Benefits of This Middleware Approach

This pattern provides several advantages over inline validation:

  • No code duplication: Validation logic exists in one place and is reused across routes.
  • Cleaner route handlers: Routes focus on business logic instead of validation concerns.
  • Easier maintenance: Update validation rules in one file, and all routes benefit.
  • Consistent error responses: All endpoints return the same error format and status codes.
  • Better testing: Validation logic can be tested independently of route logic.


Repo link

Tags: