Alright, let’s talk middleware. This is where the rubber meets the road in your server setup. Middleware functions are the bouncers, the paperwork processors, and the emergency crews of your application. They have access to the request object (req), the response object (res), and that all-important next function in the application’s request-response cycle.

In vanilla JavaScript, you’d just slap a function in there and hope for the best. But we’re better than that. We have types. TypeScript’s power here is that it lets you define exactly what each middleware expects and what it’s allowed to do, turning runtime guesswork into compile-time certainty.

The Core: RequestHandler

The fundamental type you’ll be living in is RequestHandler, imported directly from express. Let’s break it down.

import { RequestHandler } from 'express';

const myMiddleware: RequestHandler = (req, res, next) => {
  // Do something brilliant
  next();
};

The generic signature looks like this: RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>. Whoa, take a breath. It’s not as scary as it looks. These are just optional generics that allow you to lock down the types within the req object.

  • P - for the Params dictionary (e.g., req.params.userId).
  • ResBody - for the type of the response body (res.send(...)).
  • ReqBody - This is the big one. It types what you expect req.body to be.
  • ReqQuery - for the query string parameters (req.query.searchTerm).
  • Locals - for the res.locals object, which is your own personal playground for passing data between middleware.

Here’s the beautiful part: you almost never have to specify all these. TypeScript is brilliant at inference. You define the types for your specific request, and it flows through.

// Let's get specific. Imagine we have a route to update a user's profile.
// We expect a body with an email, and a userId param.

// First, define interfaces for the structures you expect
interface UpdateUserBody {
  email: string;
  avatarUrl?: string; // optional, because of course it is
}

interface UpdateUserParams {
  userId: string;
}

// Now, create a middleware that uses them
const validateUserUpdate: RequestHandler<UpdateUserParams, any, UpdateUserBody> = (req, res, next) => {
  // Look at this glory! req.body.email is typed as 'string'
  // req.params.userId is also typed as 'string'
  
  if (!req.body.email) {
    return res.status(400).send({ error: 'Email is required, my dude.' });
  }

  // Maybe do some sanitization here...

  next(); // Everything checks out, move along.
};

app.put('/user/:userId', validateUserUpdate, (req, res) => {
  // By the time the request gets here, TypeScript *and* you
  // are confident about what's in req.body and req.params.
  const { email } = req.body;
  const { userId } = req.params;
  // ... update the user
});

The Aftermath: ErrorRequestHandler

This is where a lot of people’s setups fall apart. Express has a very specific signature for error-handling middleware: it must have four arguments. Let me say that again: (err, req, res, next). Forget the err argument? Congrats, your error handler is now a regular middleware and will never be called.

TypeScript saves us from this classic foot-gun with the ErrorRequestHandler type.

import { ErrorRequestHandler } from 'express';

const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  // Finally, a use-case for the 'any' type! The error can be... anything.
  console.error('Well, this is awkward:', err);

  // Always define a default. The server shouldn't crash while telling the user it crashed.
  const statusCode = err.status || err.statusCode || 500;
  const message = statusCode === 500 ? 'Something went horribly wrong on our end.' : err.message;

  res.status(statusCode).send({ error: message });
};

// The crucial part: you MUST add this to your app *after* all other middleware and routes.
app.use(errorHandler);

Why is it after everything else? Because Express’s error handling is a waterfall. If an error gets thrown anywhere upstream, Express skips everything until it finds a middleware with that specific four-argument signature. Putting it last ensures it catches errors from all your routes and other middleware.

The res.locals Playground

The Locals generic is your best friend for stitching middleware together. It’s an object attached to the response that’s designed for passing data from one middleware to the next. Let’s type it properly.

// Define what you want to put on res.locals across your entire app
declare global {
  namespace Express {
    interface Locals {
      userId?: string; // Added by an auth middleware
      startTime: number; // For timing requests
    }
  }
}

// Now, your middleware can strongly type the assignment
const timingMiddleware: RequestHandler = (req, res, next) => {
  res.locals.startTime = Date.now(); // Perfectly typed
  next();
};

const authMiddleware: RequestHandler = async (req, res, next) => {
  try {
    const token = req.headers.authorization?.split(' ')[1];
    const payload = await verifyToken(token); // your fake function
    res.locals.userId = payload.userId; // Also perfectly typed
    next();
  } catch (error) {
    next(error); // Pass the error to our fancy ErrorRequestHandler
  }
};

// Later, in a route
app.get('/profile', authMiddleware, (req, res) => {
  // We can access it, fully typed!
  const userId = res.locals.userId;
  const duration = Date.now() - res.locals.startTime;
  res.send({ userId, requestDurationMs: duration });
});

This is the kind of type safety that makes you feel like you’re building with LEGOs instead of wet spaghetti. You define the contract once, and every part of your application knows exactly what to expect. No more // @ts-ignore because you can’t remember if you called it userID or userId. It’s all right there, and the compiler has your back.