Extending the Express API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the official documentation and the package repositories on 2026-10-29. Express is deliberately small, and its request and response objects are ordinary Node.js objects whose prototype chain has been extended with a few dozen properties and methods. The same mechanism is open to application code: a getter such as req.isMobile or a helper such as res.ok(data) can be added once and then used in every handler. The page explains how the prototypes are wired, how to add and override members safely, how to store custom settings, which lighter alternatives exist, how to describe the additions to TypeScript, and why most projects should reach for a plain middleware or helper function before touching the prototypes. The examples use ESM and the node: prefix for built-in modules, belong to the Bookshelf API that grows across this section, and contain placeholders only, never real secrets. For the members that already exist see Request and Response.

How the prototypes are wired

Every incoming request is an http.IncomingMessage created by Node.js, and every response is an http.ServerResponse. Express does not wrap them. Instead, the first layer of each application, the built-in initialisation middleware, changes the prototype of both objects so that they inherit from Express-specific prototypes:

req  ->  app.request   ->  express.request   ->  http.IncomingMessage.prototype
res  ->  app.response  ->  express.response  ->  http.ServerResponse.prototype

Three objects take part, and the difference between them is the key to extending the API:

Object Role

express.request and express.response

The shared prototypes that carry the Express members (req.get(), req.ip, res.json(), res.redirect() and so on). They are created once per process, so a change to one of them affects every Express application in that process, including applications created by libraries.

app.request and app.response

Per-application objects created with Object.create(express.request) and Object.create(express.response) inside express(). They also carry an app property pointing back to the application, which is what makes req.app and res.app work. A change here affects only that application.

req and res

The Node.js objects of one request. Their prototype is set to app.request and app.response when the request enters the application. Properties assigned directly on them live and die with the request.

When an application is mounted inside another one with app.use('/admin', adminApp), requests handled by the mounted application temporarily use its own app.request and app.response, and the parent’s prototypes are restored when the request leaves it. A getter added to the parent therefore does not exist inside the mounted application unless it is added to the mounted application as well, or to the shared express.request.

Where to add a member

The official guide on overriding the API describes the two levels. The choice follows from who must see the change:

  1. app.request and app.response for a member that belongs to one application. This is the right default: it stays invisible to other applications in the same process, which matters in test suites that create many applications and in modular monoliths that mount several.

  2. express.request and express.response for a member that every application in the process must have, typically when a shared library installs it once. It is a global side effect, so it belongs in a well-named package and never in application code that can be loaded twice.

  3. A property assigned on req or res inside a middleware, for a value that exists for one request only (the current user, a request identifier). That is not an extension of the API but ordinary per-request state, covered in Lighter alternatives: res.locals, app.locals and plain middleware.

Adding getters and helper methods

A computed property is best defined as an accessor on the prototype with Object.defineProperty. Express defines its own req.ip, req.hostname, req.secure and req.xhr the same way. The accessor runs on every read and sees the current request through this:

// src/express-extensions.js
import express from 'express';

const MOBILE_PATTERN = /Mobi|Android|iPhone|iPad/i;

/**
 * Install the Bookshelf extensions on one application.
 * @param {import('express').Application} app
 */
export function extendBookshelfApi(app) {
  // req.isMobile: a boolean computed from the User-Agent header.
  Object.defineProperty(app.request, 'isMobile', {
    configurable: true,
    enumerable: false,
    get() {
      // `this` is the current request, so a regular function is required here.
      return MOBILE_PATTERN.test(this.get('user-agent') ?? '');
    },
  });

  // req.requestedPageSize: the validated ?pageSize= value, capped at 100.
  Object.defineProperty(app.request, 'requestedPageSize', {
    configurable: true,
    enumerable: false,
    get() {
      const raw = Number.parseInt(String(this.query.pageSize ?? ''), 10);
      if (!Number.isInteger(raw) || raw < 1) return 20;
      return Math.min(raw, 100);
    },
  });

  // res.ok(data) and res.created(data, location): response helpers.
  app.response.ok = function ok(data) {
    return this.status(200).json({ data });
  };

  app.response.created = function created(data, location) {
    if (location) this.location(location);
    return this.status(201).json({ data });
  };
}
// src/server.js
import express from 'express';
import { extendBookshelfApi } from './express-extensions.js';

const app = express();
extendBookshelfApi(app);

const books = [{ id: 1, title: 'Dune' }];

app.get('/books', (req, res) => {
  const size = req.requestedPageSize;
  res.ok({ items: books.slice(0, size), mobile: req.isMobile });
});

app.post('/books', express.json(), (req, res) => {
  const book = { id: books.length + 1, title: String(req.body?.title ?? 'Untitled') };
  books.push(book);
  res.created(book, `/books/${book.id}`);
});

app.listen(3000, () => {
  console.log('Bookshelf API listening on port 3000');
});

Four details of the example are deliberate:

  • The accessors and helpers are regular functions, never arrow functions. An arrow function does not receive the request as this, so this.get would fail or read the module-level this, which is undefined in ESM.

  • enumerable: false keeps the getter out of Object.keys(req) and out of logging libraries that dump an object. Plain assignment such as app.request.isMobile = ... would create an enumerable data property that holds one value for every request, which is almost never what is wanted for a computed value.

  • configurable: true allows the property to be redefined, which a test or a second call to the installer needs. A property defined without it is permanent for the life of the process, and defining it again throws a TypeError.

  • The helpers return the result of json(), so handlers can write return res.ok(data) and keep the same early-return style as with the built-in methods.

Overriding a built-in method

The same assignment replaces an existing method. The guide shows app.response.sendStatus being replaced, and the technique applies to any member of the response prototype. The safest pattern keeps a reference to the original implementation and delegates to it, so that the override only adds behaviour:

// src/response-envelope.js
import express from 'express';

const originalJson = express.response.json;

/**
 * Wrap every res.json() of one application in a { data, meta } envelope,
 * unless the body is already an error or already an envelope.
 * @param {import('express').Application} app
 */
export function wrapJsonInEnvelope(app) {
  app.response.json = function json(body) {
    const alreadyShaped =
      body !== null &&
      typeof body === 'object' &&
      ('data' in body || 'type' in body || 'errors' in body);

    const payload = alreadyShaped
      ? body
      : { data: body, meta: { generatedAt: new Date().toISOString() } };

    // Delegate to the original so that ETag, Content-Type, JSON spaces,
    // the replacer and the escaping settings all keep working.
    return originalJson.call(this, payload);
  };
}
// src/server.js (excerpt)
import express from 'express';
import { wrapJsonInEnvelope } from './response-envelope.js';

const app = express();
wrapJsonInEnvelope(app);

app.get('/books/:id', (req, res) => {
  res.json({ id: Number(req.params.id), title: 'Dune' });
});

Overriding built-ins needs more care than adding new names, and several rules follow from how Express is implemented:

  • Keep the original and call it with .call(this, ...). Copying behaviour by hand loses the details res.json takes care of: the json replacer, json spaces and json escape settings, the default Content-Type, and the ETag that res.send computes.

  • Know the internal calls. res.json() ends by calling this.send(), res.jsonp() calls this.send() as well, and res.send() with an object body calls this.json(). Overriding both send and json therefore needs a guard against running the wrapper twice or against recursion, which is why the example above overrides only json.

  • Keep the signature compatible. Third-party middleware such as compression, morgan and express-session patch res.end and res.writeHead and assume that the rest of the API behaves as documented. A helper that returns a different type or ignores an argument breaks them in ways that are hard to trace.

  • Install the override before any route, and only once. Overriding the same method twice in two places creates a chain that nobody can read, and installing it twice on express.response wraps the wrapper.

  • An override of a core method is a global rule for the whole application. If only some routes need the behaviour, a middleware that wraps res.json for the current response only (see Lighter alternatives: res.locals, app.locals and plain middleware) is easier to reason about.

Replacing the prototype of one request

Occasionally an application needs a different set of members for a subset of routes. Because the request and response are plain objects, a middleware can point them at another prototype with Object.setPrototypeOf. That prototype should inherit from the application’s own, so that every Express member keeps working:

// src/admin-prototypes.js
import { Router } from 'express';

/**
 * A router whose requests and responses carry extra admin-only members.
 * @param {import('express').Application} app
 */
export function createAdminRouter(app) {
  const adminRequest = Object.create(app.request, {
    isAdmin: {
      configurable: true,
      get() {
        return this.get('x-admin-token') === 'replace-with-a-real-check';
      },
    },
  });

  const adminResponse = Object.create(app.response, {
    audit: {
      configurable: true,
      value: function audit(action) {
        this.set('x-audit-action', action);
        return this;
      },
    },
  });

  const router = Router();

  router.use((req, res, next) => {
    Object.setPrototypeOf(req, adminRequest);
    Object.setPrototypeOf(res, adminResponse);
    next();
  });

  router.delete('/books/:id', (req, res) => {
    if (!req.isAdmin) return res.status(403).json({ error: 'forbidden' });
    return res.audit('book.delete').status(204).end();
  });

  return router;
}

Changing a prototype at run time makes the engine discard optimisations for the object, so it is a technique for isolated cases and not for every request of a hot path. The header check in the example is a placeholder that stands for a real authentication check, which belongs in Authentication & Authorization. Prototype switching also leaks beyond the router: once a middleware has changed the prototype, every later layer for that request sees the new one, including layers outside the router that the request returns to.

Custom application settings

Express keeps its configuration in app.settings, an ordinary object that is read and written through methods. The same store accepts any name, so an application can keep its own configuration next to Express’s built-in settings such as trust proxy, etag, view engine and query parser:

Method Behaviour

app.set(name, value)

Stores value under name and returns the application. A few built-in names have side effects (setting etag, query parser or trust proxy compiles a function that Express uses later).

app.get(name)

With a single argument, returns the stored value. With a path and handlers it registers a GET route, so a setting whose name looks like a path is a bug waiting to happen.

app.enable(name) and app.disable(name)

Shortcuts for app.set(name, true) and app.set(name, false).

app.enabled(name) and app.disabled(name)

Return true when the stored value is truthy (enabled) or falsy (disabled).

// src/settings.js
import process from 'node:process';

/**
 * Store the Bookshelf configuration in the application settings.
 * @param {import('express').Application} app
 */
export function configureSettings(app) {
  app.set('bookshelf.name', 'Bookshelf API');
  app.set('bookshelf.maxPageSize', Number(process.env.MAX_PAGE_SIZE ?? 100));
  app.set('bookshelf.catalogue', new Map());

  // Feature flags read like Express's own booleans.
  if (process.env.ENABLE_EXPERIMENTAL_SEARCH === 'true') {
    app.enable('bookshelf.experimentalSearch');
  } else {
    app.disable('bookshelf.experimentalSearch');
  }

  // Built-in settings sit in the same store.
  app.disable('x-powered-by');
  app.set('json spaces', process.env.NODE_ENV === 'production' ? 0 : 2);
}
// src/server.js (excerpt)
import express from 'express';
import { configureSettings } from './settings.js';

const app = express();
configureSettings(app);

app.get('/search', (req, res) => {
  if (!req.app.enabled('bookshelf.experimentalSearch')) {
    return res.status(404).json({ error: 'search is not available' });
  }
  const limit = Math.min(Number(req.query.limit ?? 20), req.app.get('bookshelf.maxPageSize'));
  return res.json({ limit });
});

Guidelines for custom settings:

  • Use a namespace prefix such as bookshelf. for every custom name. Express reserves a growing list of names (case sensitive routing, env, etag, jsonp callback name, json escape, json replacer, json spaces, query parser, strict routing, subdomain offset, trust proxy, views, view cache, view engine, x-powered-by), and a later Express release may add a name that collides with an unprefixed one.

  • Read settings through req.app inside handlers so that the code works unchanged when the router is mounted in a different application, for instance in a test.

  • Treat settings as configuration set once at start-up. Mutating them while requests are in flight is allowed, but it is global state, and the order of writes across concurrent requests is not defined.

  • Do not store secrets or per-user data in settings. They are reachable from req.app.settings, which logging middleware and error pages sometimes serialise.

Settings and mounted applications

When app.use('/admin', adminApp) runs, the mount event makes the parent’s settings the prototype of the child’s. Reading a name that the child never set returns the parent’s value, while writing the same name in the child shadows it without changing the parent. Settings that have a default value (etag, x-powered-by, env, query parser, subdomain offset, views and the rest) are already own properties of the child, so they are not inherited. Settings with no default (view engine, json spaces, json replacer, and custom names such as bookshelf.name) are inherited. There are two exceptions: trust proxy is inherited although it has a default, and view cache is not inherited in production. A parent that sets trust proxy after mounting therefore still influences a child that never set it, and a child that sets its own view engine does not leak it upwards. Express also fires app.on('mount', callback) on the child, which is the hook for code that needs to read a parent’s setting at the moment of mounting:

// src/admin-app.js
import express from 'express';

export function createAdminApp() {
  const admin = express();

  admin.on('mount', (parent) => {
    // parent is the application that mounted this one.
    console.log('Admin mounted in', parent.get('bookshelf.name'));
  });

  admin.get('/', (req, res) => {
    res.json({ name: req.app.get('bookshelf.name'), mountpath: req.app.mountpath });
  });

  return admin;
}

Lighter alternatives: res.locals, app.locals and plain middleware

Extending a prototype is the most invasive option. Express offers lighter places for the same information, and the official guidance for most needs points to them:

Need Better tool Why

A value that lives for one request (user, request id, parsed context)

res.locals, or a property assigned in a middleware, or AsyncLocalStorage

Nothing global changes, and the value disappears with the request. res.locals is also what template engines see.

A value shared by every request and every template (site name, helpers)

app.locals

An object that Express merges into every rendered view. Its name is locals, so it has no clash with settings.

A computed value used in many handlers

A function in a module, called as getPageSize(req)

Importable, testable, searchable and type checked without any augmentation.

A response format used in many handlers

A helper function, or a middleware that attaches a function to res for this request

The behaviour is visible where it is installed and can be scoped to a router.

Cross-cutting behaviour (envelope, timing headers)

A middleware that wraps res.json for the current response only

Scoped, ordered and removable, with no prototype change.

// src/request-context.js
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export const requestContext = new AsyncLocalStorage();

/**
 * Attach per-request state without touching any prototype.
 */
export function requestContextMiddleware(req, res, next) {
  const requestId = req.get('x-request-id') ?? randomUUID();
  res.set('x-request-id', requestId);
  res.locals.requestId = requestId;

  // A per-response helper: only this response gets it, and only after this middleware.
  res.locals.ok = (data) => res.status(200).json({ data, requestId });

  requestContext.run({ requestId }, next);
}

/** Read the request id from any function called during the request. */
export function currentRequestId() {
  return requestContext.getStore()?.requestId;
}
// src/server.js (excerpt)
import express from 'express';
import { requestContextMiddleware, currentRequestId } from './request-context.js';

const app = express();

app.locals.siteName = 'Bookshelf';
app.use(requestContextMiddleware);

app.get('/books/:id', (req, res) => {
  console.log('Serving book for request', currentRequestId());
  res.locals.ok({ id: Number(req.params.id) });
});

res.locals is a plain object that is created empty for every response and is available to the views rendered during that request. app.locals is the application-wide equivalent, and both are merged into the data a view receives, with res.locals taking precedence over app.locals and the explicit render() argument taking precedence over both. Neither is a security boundary: anything stored there is visible to every template, so never put secrets in them.

Decision guide:

  1. If a function can do the job, write a function.

  2. If the value belongs to one request, use res.locals or AsyncLocalStorage.

  3. If the behaviour belongs to one router, use a middleware mounted on that router.

  4. If the member must feel native on req or res for the whole application, extend app.request or app.response.

  5. If the member must exist in every application of the process, extend express.request or express.response, and only from a library.

TypeScript declaration merging

Adding a member at run time does not tell the compiler about it, and req.isMobile fails to type check until the interfaces are merged. The types come from @types/express (the 5.x line for Express 5), which re-exports the interfaces of @types/express-serve-static-core. Both packages expose a global Express namespace with empty interfaces that exist to be augmented: Express.Request, Express.Response, Express.Locals and Express.Application.

// src/types/express-extensions.d.ts
// The file must be a module (it has an import or export) for `declare global` to work.
export {};

declare global {
  namespace Express {
    interface Request {
      /** True when the User-Agent looks like a mobile browser. */
      readonly isMobile: boolean;
      /** The validated pageSize query value, between 1 and 100. */
      readonly requestedPageSize: number;
    }

    interface Response {
      ok(data: unknown): this;
      created(data: unknown, location?: string): this;
    }

    // Typed res.locals and app.locals.
    interface Locals {
      requestId?: string;
      siteName?: string;
    }
  }
}
// src/express-extensions.ts
import type { Application, Request, Response } from 'express';

const MOBILE_PATTERN = /Mobi|Android|iPhone|iPad/i;

export function extendBookshelfApi(app: Application): void {
  Object.defineProperty(app.request, 'isMobile', {
    configurable: true,
    get(this: Request): boolean {
      return MOBILE_PATTERN.test(this.get('user-agent') ?? '');
    },
  });

  Object.defineProperty(app.request, 'requestedPageSize', {
    configurable: true,
    get(this: Request): number {
      const raw = Number.parseInt(String(this.query.pageSize ?? ''), 10);
      return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 100) : 20;
    },
  });

  app.response.ok = function ok(this: Response, data: unknown) {
    return this.status(200).json({ data });
  };

  app.response.created = function created(this: Response, data: unknown, location?: string) {
    if (location) this.location(location);
    return this.status(201).json({ data });
  };
}
// src/server.ts
import express from 'express';
import { extendBookshelfApi } from './express-extensions.js';

const app = express();
extendBookshelfApi(app);

app.get('/books', (req, res) => {
  // req.requestedPageSize and res.ok are fully typed through the merged interfaces.
  res.ok({ pageSize: req.requestedPageSize, mobile: req.isMobile });
});

app.listen(3000);

Things to know about declaration merging:

  • The .d.ts file must be part of the compilation. List it under include in tsconfig.json (or files), or import it once for its side effect. A declaration the compiler never reads silently does nothing, and the symptom is the original error on req.isMobile.

  • export {} (or any top-level import or export) turns the file into a module. Without it, declare global is an error, and the namespace Express block would instead be a global script declaration, which works but is easy to break.

  • The augmentation is a promise, not an implementation. The compiler accepts req.isMobile everywhere, even in an application that never called extendBookshelfApi. Keep the declaration and the installer in the same folder, and cover the installer with a test.

  • Inside the installer, annotate this with the Request and Response types imported from express, not with Express.Request or Express.Response. The global interfaces hold only the merged members, so this.get or this.status would not type check on them.

  • Express.Locals types res.locals and app.locals at once. Properties declared there are optional by convention, because nothing guarantees that a middleware ran before the code that reads them.

  • Fields set on the request by a library, such as req.user from Passport, are typed the same way and conflict if two libraries declare the same name with different types. The compiler reports a duplicate property error, and the fix is to align the types or rename one field.

  • The older style, declare module 'express-serve-static-core' with the interfaces Request and Response, also works and is what many tutorials show. The global Express namespace is the one the type packages document for application code, and it reaches the Request and Response types of both Express and the core package.

  • A custom setting has no type. app.get('bookshelf.maxPageSize') returns any (the settings store is a free-form map), so wrap reads in a typed function such as getMaxPageSize(app): number instead of casting at every call site.

The wider TypeScript setup, including strict mode, ESM and ts-node or tsx options, is in TypeScript with Express.

Testing extensions

An extension applied to app.request can be tested in isolation, because every call of express() creates fresh prototypes. That is the main argument for the per-application level: the tests of one installer cannot disturb another.

// test/express-extensions.test.js
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { test } from 'node:test';
import express from 'express';
import { extendBookshelfApi } from '../src/express-extensions.js';

test('req.requestedPageSize caps the value at 100', async () => {
  const app = express();
  extendBookshelfApi(app);
  app.get('/size', (req, res) => res.ok({ size: req.requestedPageSize }));

  const server = app.listen(0);
  await once(server, 'listening');
  try {
    const { port } = server.address();
    const response = await fetch(`http://127.0.0.1:${port}/size?pageSize=5000`);
    assert.equal(response.status, 200);
    assert.deepEqual(await response.json(), { data: { size: 100 } });
  } finally {
    server.close();
  }
});

Common pitfalls

Mistake Symptom Fix

Using an arrow function for a getter or helper

this is undefined or the wrong object, so this.get is not a function or Cannot read properties of undefined.

Use a regular function (or method shorthand) wherever this must be the request or response.

Assigning a computed value with app.request.isMobile = value

Every request reads the same value, set once.

Define an accessor with Object.defineProperty and a get function.

Defining a property without configurable: true and defining it again

TypeError: Cannot redefine property, typically in tests or on hot reload.

Set configurable: true, or install the extension once.

Extending express.request from application code

Another application, or a library’s application, gets the member too, and a test run in the same process sees the leftover.

Extend app.request instead, and reserve the global objects for libraries.

Overriding res.send, res.json and res.end together

Infinite recursion or a double wrapper, because json calls send and send can call json.

Override the narrowest method, keep the original, and call it with .call(this, ...).

Overriding a method with a different signature

Third-party middleware breaks in unrelated places.

Keep arguments and return values compatible, and add new names for new behaviour.

Choosing a name that Express or Node.js may define later

After an upgrade the member is shadowed, or the extension silently replaces a new built-in.

Prefix custom members (bookshelfUser) or check the API reference before upgrading.

Defining the extension after the first route or the first request

Early routes see undefined members.

Install extensions right after express() and before any app.use or route.

Forgetting that a mounted app has its own prototypes

The member is missing inside a mounted sub-application.

Install the extension on the sub-application too, or use a shared installer.

Declaring types without implementing them

It compiles, then fails at run time with req.isMobile is undefined.

Keep the .d.ts next to the installer, and test the installer.

Storing secrets in settings, app.locals or res.locals

Templates or error pages expose them.

Keep secrets in environment variables and in services, never in the shared stores.

Treating app.set names as unique

app.get('x') with one argument reads a setting, but a path-like name collides with routing.

Use namespaced names that are not path-like, such as bookshelf.name.

Changing prototypes inside a hot path

Slower requests, because the engine discards optimisations on objects whose prototype changes.

Prefer a function, a middleware or one application-level prototype installed at start-up.

What changed recently

Version Change relevant to extending the API

Express 4.x

app.request and app.response already exist as per-application prototypes created with Object.create, and the guide on overriding the API applies unchanged. The 4.x req and res members include req.param(), res.json(status, body), res.send(status) and res.sendfile(), which Express 5 removed.

Express 5.0.0 (2024-09)

The prototype layout is the same. Several req members became getters with changed semantics: req.query is a getter that is parsed on every access with the default simple query parser, req.body is undefined until a body parser runs, and req.host returns the port as well. Extensions that overwrite or shadow these members need a review. The removed methods (req.param(), res.json(status, body), res.send(status), res.sendfile(), app.del()) must not be used in overrides that delegate to them.

Express 5.1.0 (2025-03-31)

Express 5 becomes the default latest on npm. @types/express 5.x is the matching type line. No change to the extension mechanism.

Express 5.2 (2025-12-01) and 5.3 (2026-10-09)

Dependency and security updates. The prototypes, app.set and app.locals behave as before. Read History.md before upgrading, as for every minor release.

Where the books are outdated

The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20. Their coverage of the Express API and of application settings needs the following corrections for current projects:

Older practice Current practice

Configuring the application inside app.configure() blocks, or relying on bundled Connect middleware being reachable as express.bodyParser() and similar members

app.configure() was removed in Express 4 and the bundled middleware left the core. Settings are plain app.set() calls guarded by ordinary if statements, and middleware comes from separate packages.

Starting the application with express.createServer() and extending the returned server object

Removed in Express 4. Call express(), extend app.request and app.response, and let app.listen() or http.createServer(app) create the server.

Reading the application settings through app.set() and the single-argument app.get() only, with no notion of sub-application inheritance

Still valid, and the inheritance through the mount event is worth using. Settings are configuration, and a typed function is the better public interface.

Relying on req.param(name), res.send(status), res.json(status, body) or app.del() in custom helpers

Removed in Express 5. Use req.params, req.body and req.query, res.sendStatus() or res.status().send(), res.status().json() and app.delete().

Writing a plain JavaScript extension with no types

A .d.ts declaration merged into Express.Request, Express.Response and Express.Locals is the norm in TypeScript projects, and the types come from @types/express.

Adding properties directly on express.request or express.response from application code

Still possible, and still global. Prefer app.request and app.response, and keep the global objects for libraries.

Using process.env.NODE_ENV branches through app.configure('production', ...)

Read process.env.NODE_ENV (the env setting mirrors it) and branch with a normal if.