Right, so you’re building an Express app and you’ve hit the point where you need to store some custom data on the request object. Maybe you’ve just authenticated a user and you want to slap their entire profile object onto req.user. Your first instinct in TypeScript is probably to just reach out and extend the Request interface. It’s a good instinct, but if you do it naively, you’ll be smacking headfirst into a wall of red squiggles. Let’s break down why that happens and how to do it properly.

The core issue is one of type extension, not type alteration. You don’t want to globally change what express.Request is for everyone everywhere in your codebase and, more importantly, for all the third-party middleware you use. You just want to say, “In my application, on my routes, the request object will also have these extra properties.” TypeScript’s module augmentation is the scalpel for this job.

The Right Way: Module Augmentation

TypeScript allows you to “augment” or add to existing interfaces from other modules. This is the canonical, type-safe way to tell TypeScript, “Hey, the Request object from Express now has these new fields.” You do this by declaring a namespace with the same name as the module (express) and then within that, an interface with the same name as the one you want to extend (Request).

Here’s how you add a user object and a requestId string. You’d typically put this in a file like types/express.d.ts or somewhere else in your project that TypeScript will automatically include.

// types/express.d.ts
import { User } from '../src/models/User'; // Import your actual User type

declare global {
  namespace Express {
    interface Request {
      user?: User;
      requestId: string;
    }
  }
}

A few critical things to note here:

  1. We use declare global because the express namespace is in the global scope.
  2. We’re extending the Request interface inside the Express namespace.
  3. The user property is marked as optional (?) because it won’t exist on every single request (e.g., requests to your login route). The requestId is not, because we assume our middleware will always add it.

Now, in any of your route handlers, the type will just work. No more // @ts-ignore comments.

import express, { Request, Response } from 'express';

const app = express();

app.get('/profile', (req: Request, res: Response) => {
  // TypeScript now knows `req.user` might be a User object or undefined.
  if (!req.user) {
    return res.status(401).send('You think you can just waltz in here?');
  }
  res.json({ email: req.user.email, name: req.user.name }); // All type-safe!
});

The “Why” Behind the Madness

This works because of how TypeScript’s type merging functions. When you import Request from express, TypeScript isn’t grabbing some frozen, immutable interface. It’s grabbing a definition that includes your augmentations. All the official @types/express middleware types are also written this way, which is why when you add passport or express-session, they just magically add their own properties to req without you having to lift a finger. You’re playing by the same rules the ecosystem uses.

The Quick and Dirty (and Ill-Advised) Way

I know what you’re thinking: “That’s a lot of ceremony. Can’t I just use a custom interface that extends Request?” Yes, you can, and you’ll see this a lot in older blogs or from people who gave up on reading the docs. It looks like this:

interface CustomRequest extends express.Request {
  user?: User;
  requestId: string;
}

app.get('/profile', (req: CustomRequest, res: Response) => {
  // ... your code
});

The problem? You’ve now divorced yourself from the ecosystem. Any middleware you use that expects the standard Request type will throw a type error if you pass your CustomRequest to it. It’s a fragile, leaky abstraction. The module augmentation approach is globally consistent, which is what you want.

A Common Pitfall: Order of Middleware

Here’s a gotcha that’s more about runtime than compile time. Let’s say you have a middleware that adds your requestId.

app.use((req, res, next) => {
  req.requestId = generateId(); // Let's assume this is a string
  next();
});

TypeScript is now happy. But what if you have another middleware that tries to use req.requestId before this one runs? At runtime, it will be undefined, and you’ll get a crash, even though TypeScript assured you it was a string. The lesson: TypeScript can’t save you from your own messed-up middleware order. It only checks types, not existence or state. Always be mindful of the order in which your middleware is mounted. The type system assumes you’ve built your application correctly. It’s a brilliant assistant, not a psychic babysitter.

Stick with module augmentation. It’s the one true path, and it keeps you from having to explain to your teammates why all the types are broken.