TypeScript with Express

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; Express 4.22.3 is the latest 4.x release), @types/express 5.0.6 (which depends on @types/express-serve-static-core 5.1.3), Node.js 24 LTS and TypeScript 5.9, all checked on 2026-10-29. Express itself is written in JavaScript and ships no type declarations, so everything on this page is about the community typings from DefinitelyTyped and about how Node.js can run TypeScript without a build step. The examples extend the Bookshelf bookshelf-api used throughout the section: the same books API as in Getting Started, now fully typed. For the language itself see the TypeScript reference, and for the runtime side Node.js.

What you install

Express does not contain its own type declarations. Types for the framework live in the @types/express package, and Node.js types (http.IncomingMessage, process, crypto, …​) in @types/node. Both are development dependencies, because they only matter to the compiler. The official Installing page also recommends the same for any library that TypeScript reports as untyped, for example @types/cors next to cors.

npm init -y
npm install express@5
npm install --save-dev typescript @types/express @types/node

Points worth knowing about the packages:

  • @types/express is a thin layer. The interesting declarations (Request, Response, RequestHandler, ErrorRequestHandler, NextFunction, the router and the application) live in @types/express-serve-static-core, which @types/express re-exports and which the module-augmentation section below relies on. @types/express also depends on @types/body-parser and @types/serve-static for the matching parts of the API; @types/qs (for req.query) and @types/send come in through the core package.

  • The major version of @types/express follows the Express major version. Use @types/express@5 with Express 5 and @types/express@4 with Express 4. Mixing them gives wrong parameter types (see the wildcard section) or errors for methods that exist in only one version.

  • The types are maintained by the community, not by the Express project, so they can lag a feature by a few days. A brand new API (for example app.query() in Express 5.3) may be present in the runtime before the typings describe it.

  • Match @types/node to the Node.js major version you run in production, not the newest one you can install. The types describe APIs that do not exist on older runtimes.

The official tsconfig.json

The Installing page gives a tsconfig.json that mirrors how Node.js itself runs TypeScript and makes the compiler reject syntax that Node.js cannot strip.

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

What each option does for an Express project:

Option Effect

module: nodenext

Follows Node.js module resolution, including the rule that a project with "type": "module" in package.json is ESM and that relative imports need a file extension (./books.ts).

rewriteRelativeImportExtensions

Lets you write .ts in relative imports (which Node.js type stripping requires) and have tsc rewrite them to .js when you do emit code.

erasableSyntaxOnly

Errors on TypeScript features that need code generation rather than simple removal: enum, runtime namespace declarations and constructor parameter properties. Node.js cannot strip those.

verbatimModuleSyntax

Forces import type for type-only imports, so the stripped file keeps exactly the imports that exist at runtime.

noEmit

tsc only type-checks. Node.js runs the .ts sources directly.

strict

Enables the strict family. It is what gives req.params, req.query and req.body useful types, so keep it on.

skipLibCheck

Skips checking the .d.ts files of dependencies, which avoids version clashes between type packages.

If you do compile to JavaScript (for a library, or for Node.js versions that cannot strip types), remove noEmit, add outDir and run tsc before node. For an application on a current Node.js LTS the no-build setup is simpler.

Running TypeScript: type stripping and tsc

Node.js can execute .ts files directly. It strips the type annotations and runs the remaining JavaScript, without checking types. The Express Installing page states the requirement as Node.js 22.18.0 or later (or 23.6.0 or later on the 23 line) with TypeScript 5.8 or later. Type stripping is enabled by default since those releases, no longer prints an experimental warning from 22.18 and 24.3, and is marked stable from Node.js 24.12 and 25.2. The former --experimental-transform-types flag, which also handled enum, was removed in Node.js 26.

node src/server.ts                           # run directly, no build step
node --watch --env-file=.env src/server.ts   # restart on change, load .env
npx tsc                                      # type-check only (noEmit)

Consequences you must plan for:

  • Stripping is not checking. A type error does not stop node src/server.ts; only tsc reports it. Run tsc in the editor, in a typecheck npm script and in CI.

  • Only erasable syntax works. This is why the official tsconfig.json sets erasableSyntaxOnly: use union types of string literals instead of enum, and plain class fields assigned in the constructor instead of parameter properties.

  • Imports must be written with their real extension (./app.ts) and types imported with import type, because Node.js does not resolve or elide anything for you.

  • Node.js refuses to strip types for files below node_modules, so a dependency must ship JavaScript.

  • There is no tsconfig.json handling at runtime. Features such as path aliases (paths) are not applied by Node.js.

Production images can run the same .ts entry point, or run a compiled dist folder; both are discussed in Deployment & Containers.

A typical package.json for the Bookshelf API ties the pieces together:

{
  "name": "bookshelf-api",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env src/server.ts",
    "dev": "node --watch --env-file=.env src/server.ts",
    "typecheck": "tsc"
  },
  "dependencies": {
    "express": "^5.3.0",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^24.0.0",
    "typescript": "^5.9.0"
  }
}

Hello world and inference

The official TypeScript hello world annotates the application and the handler parameters.

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

const app: Express = express();

app.get('/', (req: Request, res: Response) => {
  res.send('Hello World!');
});

app.listen(3000);

You rarely need those annotations. When a handler is passed directly to a route method or to app.use(), TypeScript infers req, res and next from the overload, and it also infers route parameters from the path string. In app.get('/books/:id', ...) the type of req.params.id is string without any annotation. Add explicit types only where there is no contextual type: error-handling middleware, whose four-argument signature is not inferred, and handlers defined separately from the route.

Typing Request, Response and handlers

The central types come from express-serve-static-core and are re-exported by express.

Type Meaning

Request<P, ResBody, ReqBody, ReqQuery, Locals>

The request. P types req.params, ReqBody types req.body, ReqQuery types req.query and ResBody is carried along so that req.res agrees with the response. The defaults are ParamsDictionary, any, any, ParsedQs and Record<string, any>.

Response<ResBody, Locals>

The response. ResBody constrains what res.json() and res.send() accept; Locals types res.locals.

NextFunction

next(), next(err), next('route') and next('router').

RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>

A whole middleware or route function with the same generics. Its return type is unknown, so an async handler that returns a promise type-checks, which matches Express 5 forwarding rejected promises to the error handlers.

ErrorRequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>

The four-argument error middleware (err, req, res, next). The err parameter is any.

Express, Router

The application returned by express() and the router returned by express.Router().

The generics let a handler state its contract: the shape of the path parameters, of the JSON it returns, of the body it expects and of the query string.

// src/books.ts
import type { ErrorRequestHandler, NextFunction, Request, RequestHandler, Response } from 'express';

export interface Book {
  id: string;
  title: string;
  author: string;
  year: number;
}

export interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail?: string;
}

type BookParams = { id: string };
type ListQuery = { author?: string; limit?: string };

const books = new Map<string, Book>();

// Request<Params, ResBody, ReqBody, Query>, written out
export const getBook = (
  req: Request<BookParams, Book | ProblemDetails>,
  res: Response<Book | ProblemDetails>,
): void => {
  const book = books.get(req.params.id);
  if (!book) {
    res.status(404).json({ type: 'about:blank', title: 'Not Found', status: 404 });
    return;
  }
  res.json(book);
};

// the same idea with RequestHandler<Params, ResBody, ReqBody, Query>
export const listBooks: RequestHandler<{}, Book[], never, ListQuery> = (req, res) => {
  const limit = Number(req.query.limit ?? 20);
  const all = [...books.values()].filter((b) => !req.query.author || b.author === req.query.author);
  res.json(all.slice(0, limit));
};

// an error handler cannot be inferred: type the whole function
export const problemHandler: ErrorRequestHandler = (err, _req, res, next) => {
  if (res.headersSent) {
    next(err);
    return;
  }
  const status = typeof err?.status === 'number' ? err.status : 500;
  const body: ProblemDetails = {
    type: 'about:blank',
    title: status === 500 ? 'Internal Server Error' : 'Error',
    status,
  };
  res.status(status).type('application/problem+json').json(body);
};

// a plain middleware function with typed parameters
export function withRequestId(req: Request, res: Response, next: NextFunction): void {
  req.requestId = crypto.randomUUID();
  res.locals.startedAt = Date.now();
  res.setHeader('X-Request-Id', req.requestId);
  next();
}

Details and pitfalls:

  • The generics are positional. To type only the query you must also give the first three arguments, usually {} (or ParamsDictionary), any and any. A type alias such as type Handler<Q> = RequestHandler<{}, any, any, Q> removes the repetition.

  • req.query is typed as ParsedQs by default and, since Express 5, is parsed with the "simple" query parser, so its values are strings or arrays of strings and are never numbers. Declare a query type with string properties (as in ListQuery above) and convert explicitly. The type does not validate anything: a client can send limit twice, which arrives as an array. Validate with a schema when it matters.

  • req.params is a plain object whose properties are string for named parameters. Typing it with { id: string } documents the route but is only checked against what the path string says when the handler is inlined.

  • A handler typed with Response<Book> cannot call res.json() with anything but a Book. When a handler may answer with several shapes, use a union in ResBody, as getBook does.

  • Handlers do not return the response. Write res.json(...); return; rather than return res.json(...): the declared return type is void and returning a Response from a function annotated void is rejected in an arrow function with an explicit return type.

  • The ResBody of consecutive handlers in one route must agree, because they are checked together as a list (see the validation section below).

  • Do not write next as optional or leave it out of an error handler. Express decides whether a function is an error handler by its declared arity of four parameters, so keep next even if you do not call it. Add a leading underscore to an unused parameter name (_req) to silence lint rules without changing the arity.

Wildcard and optional parameters

Express 5 uses path-to-regexp 8 (through the router 2.2 module), where a wildcard must be named: /files/*splat, or /files/{*splat} when the path without a suffix must also match. A wildcard parameter is delivered as an array of the path segments, not as one string. @types/express 5 reflects this: the parameter type derived from a path string is string for :id and string[] for *splat; for an optional brace group, the parameters inside are optional.

import express from 'express';

const app = express();

// req.params.splat: string[]   ('/files/a/b/c' gives ['a', 'b', 'c'])
app.get('/files/*splat', (req, res) => {
  const segments: string[] = req.params.splat;
  res.json({ segments, path: segments.join('/') });
});

// optional group: req.params.format: string | undefined
app.get('/books{.:format}', (req, res) => {
  res.json({ format: req.params.format ?? 'json' });
});

// the router.param / req.params pitfall: a hand-written params type can lie
app.get('/authors/:authorId/books/:bookId', (req, res) => {
  const { authorId, bookId } = req.params; // both string, inferred from the path
  res.json({ authorId, bookId });
});

Inference from the path only works for a string literal passed straight to the route method. If the path is stored in a variable of type string, or the handler is defined elsewhere, req.params falls back to ParamsDictionary, whose values are string | string[]. In those cases give the first generic yourself (Request<{ splat: string[] }>). In Express 4 an unnamed * produced a single string, and Express 5 names the wildcard and returns an array; this is one reason to keep the Express and typings majors aligned. The full Express 5 path syntax is in Route Paths & Parameters.

Augmenting req and res

Middleware often attaches data to the request or response: a request id, the authenticated user, a database handle. Plain assignment such as req.user = user fails to compile, because user is not a property of Request. The supported fix is to augment the interfaces rather than cast to any everywhere.

There are two places to add members:

  • Request and Response properties: augment the module express-serve-static-core, which declares the interfaces that express re-exports. Augmenting express itself does not work reliably, because express only re-exports them.

  • res.locals: augment the Express.Locals interface in the global Express namespace. The type then applies to res.locals and to the Locals generic default. There are also empty Express.Request and Express.Response interfaces that libraries such as Passport use to add req.user.

// src/auth.ts
export interface AuthUser {
  id: string;
  role: 'admin' | 'reader';
}
// src/express.d.ts
import type { AuthUser } from './auth.ts';

declare module 'express-serve-static-core' {
  interface Request {
    requestId: string;
    user?: AuthUser;
  }
}

declare global {
  namespace Express {
    interface Locals {
      startedAt: number;
    }
  }
}

Rules that make the augmentation work:

  • The file must be a module: it needs at least one top-level import or export, as the import type above provides. Without it, declare module '...' replaces the module’s declarations instead of merging into them, and everything imported from express breaks. If you have nothing to import, add export {}.

  • The file must be part of the compilation. Put it under the folder listed in include of tsconfig.json. A stray file outside include is ignored and the property appears to be missing.

  • Properties set by a middleware are only present after that middleware ran. If a property is not always set, declare it optional (user?: AuthUser) so that the compiler forces handlers to narrow it, as with req.user before authentication. Declare it as required only for data that a global middleware always sets, like requestId.

  • The declare global { namespace Express { ... } } form uses an ambient namespace, which is erased entirely, so it is allowed under erasableSyntaxOnly. Do not write a runtime namespace with values.

  • The .d.ts extension means the file is only read by tsc and by the editor. Node.js never loads it, which is correct, because it only holds types.

With the declarations in place, the middleware from the previous section compiles and the handlers see the new members without casts:

// src/app.ts
import express, { type Express } from 'express';
import { getBook, listBooks, problemHandler, withRequestId } from './books.ts';
import { createBook, newBookSchema, validateBody } from './validation.ts';

export function createApp(): Express {
  const app = express();
  app.use(withRequestId);
  app.use(express.json());

  app.get('/api/v1/books', listBooks);
  app.get('/api/v1/books/:id', getBook);
  app.post('/api/v1/books', validateBody(newBookSchema), createBook);

  app.get('/files/*splat', (req, res) => {
    const segments: string[] = req.params.splat;
    res.json({ segments });
  });

  app.use(problemHandler);
  return app;
}

Typed augmentation only describes what is there; it does not create anything. How to actually replace or extend members of app.request and app.response at runtime is covered in Extending the Express API.

Typing validated bodies

req.body is any by default, and with Express 5 it is undefined until a body parser has run. A type annotation on the body (Request<{}, Book, NewBook>) is a promise from the programmer, not a check: a client can send anything. The safe pattern is to derive the type from a runtime schema and let a validation middleware turn the promise into a fact.

The example uses Zod, but the pattern is the same with Joi, Ajv or Valibot: one schema, a type inferred from it, and a middleware that parses req.body and either answers 400 or replaces the body with the parsed value.

// src/validation.ts
import type { Request, RequestHandler, Response } from 'express';
import { z } from 'zod';
import type { Book, ProblemDetails } from './books.ts';

export const newBookSchema = z.object({
  title: z.string().min(1),
  author: z.string().min(1),
  year: z.number().int().min(1450).max(2100),
});
export type NewBook = z.infer<typeof newBookSchema>;

// generic validator: the handler type carries the parsed body type T
export function validateBody<T>(schema: z.ZodType<T>): RequestHandler<{}, any, T> {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const problem: ProblemDetails = {
        type: 'about:blank',
        title: 'Bad Request',
        status: 400,
        detail: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '),
      };
      res.status(400).type('application/problem+json').json(problem);
      return;
    }
    req.body = result.data;
    next();
  };
}

// the final handler declares the body it can trust
export const createBook = (req: Request<{}, Book, NewBook>, res: Response<Book>): void => {
  const book: Book = { id: crypto.randomUUID(), ...req.body };
  res.status(201).location(`/api/v1/books/${book.id}`).json(book);
};

How the types fit together:

  • validateBody(newBookSchema) returns a RequestHandler<{}, any, NewBook>. In app.post('/api/v1/books', validateBody(newBookSchema), createBook) TypeScript checks the whole handler list: the body type of the validator must match the body type of createBook. Passing a validator for a different schema is a compile error.

  • The response body type of the validator is any on purpose. Handlers in one list share ResBody, and a validator that wrote ProblemDetails there would conflict with createBook answering Book. With any, the validator can send its error and the next handler can still declare its own success type. Declare the problem shape as a separate type and use it where you build the error, as above, rather than in the generic.

  • Type safety ends at the request boundary: the compiler cannot know the validator ran. Keeping validation in the route declaration, next to the handler that depends on it, is what keeps the two in sync. The complete validation story (schemas, error formats, query and parameter validation, express-validator, Ajv) is in Validation & Input Handling.

  • Do not annotate req.body as the model type itself (Book) for input: the client does not send the server-generated id. Use a separate input type, as NewBook does, and derive it from the schema so they cannot drift.

  • Remember to mount express.json() before the route. Without it req.body is undefined at runtime even though the type says otherwise, and safeParse(undefined) produces a 400 for every request.

Serve the typed API and exercise it from the shell:

node --env-file=.env src/server.ts &

curl -s -X POST localhost:3000/api/v1/books \
  -H 'content-type: application/json' \
  -d '{"title":"Dune","author":"Frank Herbert","year":1965}'
curl -s -X POST localhost:3000/api/v1/books \
  -H 'content-type: application/json' \
  -d '{"title":"","year":"x"}'
{"id":"3f0b6c1e-0000-4000-8000-000000000000","title":"Dune","author":"Frank Herbert","year":1965}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"title: Too small: expected string to have >=1 characters; author: Invalid input: expected string, received undefined; year: Invalid input: expected number, received string"}

The server file for these examples reads the port from the environment (a placeholder PORT=3000 in .env) and uses the Express 5 listen error callback:

// src/server.ts
import { createApp } from './app.ts';

const port = Number(process.env.PORT ?? 3000);
const server = createApp().listen(port, (error?: Error) => {
  if (error) {
    throw error;
  }
  console.log(`bookshelf-api listening on http://localhost:${port}`);
});

process.on('SIGTERM', () => server.close());

What changed recently

  • Express 5.0 (2024-09-10): rejected promises returned by handlers are forwarded to the error handlers at runtime. The current @types/express 5 typings infer wildcard parameters as string[] and optional brace groups as optional properties, and declare handler return types as unknown, so async handlers need no void wrapper.

  • Express 5.1 (2025-03-31): became the latest tag on npm; the default for npm install express.

  • Official TypeScript guidance: the Installing page now includes a TypeScript section (types, tsconfig.json, type stripping, tsc), and 5.x samples are offered as .cjs, .mjs and .ts.

  • Node.js type stripping: enabled by default in Node.js 22.18 and 23.6, warning-free from 22.18 and 24.3, stable in 24.12 and 25.2. --experimental-transform-types was removed in 26.

  • TypeScript 5.8 and later: erasableSyntaxOnly (5.8) and rewriteRelativeImportExtensions (5.7) are the options that make the compiler match what Node.js can run.

Where the books are outdated

All four books the section is based on are written in JavaScript against Express 4 (4.0 to 4.18) and contain no TypeScript. Related statements and habits that readers carry over from them and from older TypeScript tutorials:

Older practice Today

Running TypeScript through a build step or a runner (ts-node, nodemon with ts-node, Babel presets) for every development run

Node.js strips types natively from 22.18 and 23.6; use node --watch for restarts and tsc only to type-check. A compile step remains valid for libraries and for older runtimes.

import express = require('express') and esModuleInterop workarounds, CommonJS-only examples

Express 5 works from both module systems. The official setup uses ESM with "module": "nodenext" and "type": "module".

enum, runtime namespace and constructor parameter properties in server code

Not strippable by Node.js. Prefer string-literal unions and plain fields, and let erasableSyntaxOnly enforce it.

@types/express 4 with Express 5, or no types at all

Use @types/express 5, which matches the Express 5 path syntax. In Express 4 the unnamed * produced a single string in req.params[0].

req.body typed as the model and trusted

req.body is any (and undefined before express.json() in Express 5). Validate against a schema and type the validated value.

app.use(bodyParser.json()) with @types/body-parser

express.json() and express.urlencoded() ship in Express 4.16+ and 5; @types/express covers them.

res.send(status, body) or res.json(status, body) signatures

Removed in Express 5 and absent from @types/express 5. Use res.status(code).json(body).

The complete list of book statements that no longer hold is in Migrating & What’s Changed.