Extending the Request Type
Middleware in a real Express app routinely attaches data to the request object — an authenticated req.user, a per-request req.context, a parsed req.tenant. TypeScript knows nothing about these properties out of the box, so accessing them produces a compile error. The clean, idiomatic fix is declaration merging: you teach TypeScript that the Express Request interface has extra fields, and from then on every handler in your codebase sees them with full type safety. This page shows how to structure those global declarations correctly so they apply everywhere without imports.
Why req.user fails to type-check
When auth middleware writes req.user = await findUser(token), the Express Request interface has no user member, so the compiler rejects both the assignment and every later read:
app.get('/me', (req: Request, res: Response) => {
res.json({ id: req.user.id }); // error
});
Output:
error TS2339: Property 'user' does not exist on type 'Request<...>'.
You could cast every access with (req as any).user, but that throws away type safety project-wide. Declaration merging is the proper alternative: it permanently extends the interface that Express already exposes.
Augmenting the Express namespace
Express types are published under the global Express namespace, and @types/express-serve-static-core declares interface Request inside it. Because TypeScript interfaces are open, a second declaration of the same interface merges into the original rather than replacing it. Create a declaration file and re-open Express.Request:
// src/types/express.d.ts
import 'express';
declare global {
namespace Express {
interface User {
id: string;
email: string;
roles: string[];
}
interface Request {
user?: User;
context: {
requestId: string;
startedAt: number;
};
}
}
}
export {};
Two details make this file work. The import 'express' (or export {}) turns the file into a module, which is required for declare global to be valid. And user is marked optional (?) because it only exists after auth middleware runs — a route mounted before that middleware would otherwise lie about the property being present.
Tip: The empty
export {}at the bottom is not decoration. Without at least one top-levelimport/export, TypeScript treats the file as a global script anddeclare globalbecomes an error.
Making sure the file is included
A .d.ts augmentation only takes effect if the compiler actually loads it. The simplest approach is to keep it inside your rootDir so the include glob picks it up:
{
"include": ["src/**/*.ts"]
}
If you store declarations outside src/, point at them explicitly with typeRoots or add the directory to include. Once the file is in the program, no import is needed in your application code — the merge is global.
Using the extended request
With the declaration in place, the original error disappears and req.user is fully typed everywhere. The auth middleware below populates it, and downstream handlers read it with autocompletion:
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
export async function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.header('authorization')?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'missing token' });
const user = await verifyToken(token); // returns Express.User | null
if (!user) return res.status(401).json({ error: 'invalid token' });
req.user = user;
next();
}
// src/routes/me.ts
import { Router, Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
const router = Router();
router.get('/me', authenticate, (req: Request, res: Response) => {
if (!req.user) return res.status(401).json({ error: 'unauthenticated' });
res.json({ id: req.user.id, email: req.user.email, roles: req.user.roles });
});
export default router;
The if (!req.user) guard does double duty: it handles the genuine unauthenticated case and narrows the optional user to a defined Express.User, so the property reads below compile cleanly.
Output (GET /me with a valid token):
{
"id": "u_42",
"email": "[email protected]",
"roles": ["admin"]
}
Augmentation vs. casting
| Approach | Type safety | Scope | Verdict |
|---|---|---|---|
(req as any).user | None — opts the whole expression out | Per access site | Avoid |
Custom interface AuthRequest extends Request | Strong, but you must annotate every handler | Per handler | Verbose, error-prone |
| Global declaration merging | Strong and automatic | Whole project | Recommended |
A custom subtype like AuthRequest seems tempting, but Express middleware signatures expect the base Request, so passing an AuthRequest handler often requires casts at the app.use boundary. Declaration merging avoids that friction entirely.
Warning: Mark middleware-supplied fields optional unless you can guarantee the middleware runs for every route. A non-optional
req.userthat is actuallyundefinedat runtime is a silent lie to the type system and a classic source ofCannot read properties of undefinedcrashes.
Notes for Express 5
The augmentation target is identical in Express 5.x — Request still lives in Express namespace via @types/express-serve-static-core. Just ensure your installed @types/express major version matches your Express major version so the merged interface lines up with the runtime router signatures.
Best Practices
- Keep all augmentations in a dedicated
src/types/express.d.tsso the global declarations are discoverable in one place. - Always include
import 'express'(or a trailingexport {}) to make the file a module — otherwisedeclare globalwill not compile. - Mark properties added by middleware as optional and narrow them with a runtime guard before use.
- Reuse a shared
Express.Userinterface (or import your domainUsertype) instead of redeclaring the shape inline onRequest. - Confirm the
.d.tsfile is covered by yourtsconfig.jsonincludeglob; a declaration the compiler never loads has no effect. - Prefer global merging over per-handler
AuthRequestsubtypes to keep middleware signatures compatible with Express’s expectedRequest.