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
Source: Express: Installing
Points worth knowing about the packages:
-
@types/expressis 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/expressre-exports and which the module-augmentation section below relies on.@types/expressalso depends on@types/body-parserand@types/serve-staticfor the matching parts of the API;@types/qs(forreq.query) and@types/sendcome in through the core package. -
The major version of
@types/expressfollows the Express major version. Use@types/express@5with Express 5 and@types/express@4with 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/nodeto 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"]
}
Source: Express: Installing
What each option does for an Express project:
| Option | Effect |
|---|---|
|
Follows Node.js module resolution, including the rule that a project with |
|
Lets you write |
|
Errors on TypeScript features that need code generation rather than simple removal: |
|
Forces |
|
|
|
Enables the strict family. It is what gives |
|
Skips checking the |
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)
Source: Node.js: Modules: TypeScript
Consequences you must plan for:
-
Stripping is not checking. A type error does not stop
node src/server.ts; onlytscreports it. Runtscin the editor, in atypechecknpm script and in CI. -
Only erasable syntax works. This is why the official
tsconfig.jsonsetserasableSyntaxOnly: use union types of string literals instead ofenum, 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 withimport 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.jsonhandling 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);
Source: Express: Installing
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 |
|---|---|
|
The request. |
|
The response. |
|
|
|
A whole middleware or route function with the same generics. Its return type is |
|
The four-argument error middleware |
|
The application returned by |
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
{}(orParamsDictionary),anyandany. A type alias such astype Handler<Q> = RequestHandler<{}, any, any, Q>removes the repetition. -
req.queryis typed asParsedQsby 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 inListQueryabove) and convert explicitly. The type does not validate anything: a client can sendlimittwice, which arrives as an array. Validate with a schema when it matters. -
req.paramsis a plain object whose properties arestringfor 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 callres.json()with anything but aBook. When a handler may answer with several shapes, use a union inResBody, asgetBookdoes. -
Handlers do not return the response. Write
res.json(...); return;rather thanreturn res.json(...): the declared return type isvoidand returning aResponsefrom a function annotatedvoidis rejected in an arrow function with an explicit return type. -
The
ResBodyof consecutive handlers in one route must agree, because they are checked together as a list (see the validation section below). -
Do not write
nextas 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 keepnexteven 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 });
});
Source: Express: Routing
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:
-
RequestandResponseproperties: augment the moduleexpress-serve-static-core, which declares the interfaces thatexpressre-exports. Augmentingexpressitself does not work reliably, becauseexpressonly re-exports them. -
res.locals: augment theExpress.Localsinterface in the globalExpressnamespace. The type then applies tores.localsand to theLocalsgeneric default. There are also emptyExpress.RequestandExpress.Responseinterfaces that libraries such as Passport use to addreq.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
importorexport, as theimport typeabove provides. Without it,declare module '...'replaces the module’s declarations instead of merging into them, and everything imported fromexpressbreaks. If you have nothing to import, addexport {}. -
The file must be part of the compilation. Put it under the folder listed in
includeoftsconfig.json. A stray file outsideincludeis 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 withreq.userbefore authentication. Declare it as required only for data that a global middleware always sets, likerequestId. -
The
declare global { namespace Express { ... } }form uses an ambient namespace, which is erased entirely, so it is allowed undererasableSyntaxOnly. Do not write a runtimenamespacewith values. -
The
.d.tsextension means the file is only read bytscand 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 aRequestHandler<{}, any, NewBook>. Inapp.post('/api/v1/books', validateBody(newBookSchema), createBook)TypeScript checks the whole handler list: the body type of the validator must match the body type ofcreateBook. Passing a validator for a different schema is a compile error. -
The response body type of the validator is
anyon purpose. Handlers in one list shareResBody, and a validator that wroteProblemDetailsthere would conflict withcreateBookansweringBook. Withany, 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.bodyas the model type itself (Book) for input: the client does not send the server-generatedid. Use a separate input type, asNewBookdoes, and derive it from the schema so they cannot drift. -
Remember to mount
express.json()before the route. Without itreq.bodyisundefinedat runtime even though the type says otherwise, andsafeParse(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"}
Source: Express: Hello world
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());
Source: Express: app.listen()
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/express5 typings infer wildcard parameters asstring[]and optional brace groups as optional properties, and declare handler return types asunknown, soasynchandlers need novoidwrapper. -
Express 5.1 (2025-03-31): became the
latesttag on npm; the default fornpm 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,.mjsand.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-typeswas removed in 26. -
TypeScript 5.8 and later:
erasableSyntaxOnly(5.8) andrewriteRelativeImportExtensions(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 ( |
Node.js strips types natively from 22.18 and 23.6; use |
|
Express 5 works from both module systems. The official setup uses ESM with |
|
Not strippable by Node.js. Prefer string-literal unions and plain fields, and let |
|
Use |
|
|
|
|
|
Removed in Express 5 and absent from |
The complete list of book statements that no longer hold is in Migrating & What’s Changed.