The short answer
Add a .d.ts file that extends the interface the package exposes for this. For Express it is the global Express.Request:
export {};
declare global {
namespace Express {
interface Request {
/** Set by the authenticate middleware. */
user?: { id: string; role: "customer" | "staff" };
}
}
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js", "*.d.ts"]
} Every req in the project now has an optional user, set by the middleware and read by the handlers:
import express from "express";
/** @satisfies {import("express").RequestHandler} */
const authenticate = (req, res, next) => {
const token = req.get("authorization");
if (token === "Bearer staff-token") req.user = { id: "u-1", role: "staff" };
next();
};
const app = express();
app.use(authenticate);
app.get("/admin", (req, res) => {
if (req.user?.role !== "staff") return res.status(403).end();
res.json({ hello: req.user.id });
});import express, { type RequestHandler } from "express";
const authenticate = ((req, res, next) => {
const token = req.get("authorization");
if (token === "Bearer staff-token") req.user = { id: "u-1", role: "staff" };
next();
}) satisfies RequestHandler;
const app = express();
app.use(authenticate);
app.get("/admin", (req, res) => {
if (req.user?.role !== "staff") return res.status(403).end();
res.json({ hello: req.user.id });
});jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js", "*.d.ts"]
} Why it works
Interfaces with the same name merge. @types/express declares Request so that it extends an (empty) global interface Express.Request; adding members to that global interface adds them to every Express request type. export {} makes the file a module, and declare global lets a module add to the global scope. The property is optional because routes that run before the middleware, or requests without a token, have no user.
The same thing in TypeScript
The TypeScript equivalent tab of the second example uses the same declaration file; only the handler moves from @satisfies to satisfies. Augmentations are declaration-file code in both kinds of project.
What goes wrong
Without the declaration, assigning req.user is TS2339: Express’s types do not know the property:
import express from "express";
/** @satisfies {import("express").RequestHandler} */
const authenticate = (req, res, next) => {
req.user = { id: "u-1", role: "staff" };error TS2339: Property 'user' does not exist on type 'Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>'.
next();
};
express().use(authenticate);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} With it, the added property is checked like any other: a role outside "customer" | "staff" is TS2322:
import express from "express";
/** @satisfies {import("express").RequestHandler} */
const authenticate = (req, res, next) => {
req.user = { id: "u-1", role: "admin" };error TS2322: Type '"admin"' is not assignable to type '"customer" | "staff"'.
next();
};
express().use(authenticate);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js", "*.d.ts"]
} Gotchas
- The
.d.tsmust be in the program. List it ininclude; an augmentation in a file nobody includes has no effect. - Augmenting a package module needs
export {}or an import. In a file without top-levelimportorexport,declare module "express"declares a new module that hides the real one instead of extending it:express()is then TS2349, “This expression is not callable”. res.localsis the typed alternative. Data for one request can also go inres.locals, typed withRequestHandler’s fifth type parameter; see the middleware recipe.
Questions
Why declare global { namespace Express … } and not declare module "express"?
@types/express builds its Request from a global Express.Request interface that exists for exactly this purpose. Other packages expose their types differently; their documentation or their .d.ts files say which interface to extend.
Can I augment a type from a JavaScript file?
No. Augmentation uses declare and interface merging, which only declaration files (or TypeScript files) can write. The .d.ts file sits beside your JavaScript and is listed in include.
Sources
- Declaration Merging (module augmentation and global augmentation) · TypeScript handbook
- @types/express · npm
- Writing middleware · Express docs
Last reviewed 21 October 2026.