NodeJSTyping an Express App

Typing an Express App

Express and TypeScript pair well, but Express's flexible, untyped-by-design API needs a few patterns to type cleanly. You install the types, learn the Request/Response generics for params/body/query, handle the reality that req.body is any (and why you must validate it), type middleware and errors correctly, and augment Request to attach things like req.user. This page covers all of that — the goal is end-to-end type safety from route handler to response, without fighting the framework.

Setup and a typed handler

Bash
npm install express
npm install --save-dev @types/express @types/node typescript

TS
import express, { Request, Response } from 'express'

const app = express()
app.use(express.json())

// A basic typed handler — req and res carry Express's types:
app.get('/health', (req: Request, res: Response) => {
  res.json({ status: 'ok' })     // res.json is typed; autocompletion works
})
Install `@types/express`; handlers receive typed `Request`/`Response` objects with full autocomplete
Express ships no types itself, so you add **`@types/express`** (a dev dependency) to get definitions for `Request`, `Response`, `NextFunction`, `Router`, and the app/middleware APIs. With those installed, a route handler's `req` and `res` are fully typed: `res.json()`, `res.status()`, `res.send()`, `req.headers`, `req.params`, and the rest autocomplete and type-check. You often don't even need to annotate `(req, res)` explicitly — when you pass the handler directly to `app.get(...)`, TypeScript infers their types from Express's overloads. The interesting work is typing the *parts of the request you control* — route params, query strings, and the JSON body — which Express exposes through generics.
Typing params, query, and body

TS
// Request is generic: Request<Params, ResBody, ReqBody, Query>
interface CreateUserBody { name: string; email: string }
interface UserParams { id: string }          // route params are ALWAYS strings

app.post(
  '/users/:id',
  (req: Request<UserParams, unknown, CreateUserBody>, res: Response) => {
    const { id } = req.params       // typed as string
    const { name, email } = req.body // typed as CreateUserBody  ← but see the warning!
    res.status(201).json({ id, name, email })
  },
)
Typing `req.body` does NOT validate it — the annotation is a lie until you check the data at runtime
Express's `Request` is **generic**: `Request<Params, ResBody, ReqBody, Query>` lets you type the route params, response body, request body, and query string. This gives lovely autocomplete — but here lies the most dangerous TypeScript-on-the-server trap. Annotating `req.body` as `CreateUserBody` does **not** make it so; [types are erased at runtime](/nodejs/typescript-intro), `req.body` is really `any`, and the actual incoming JSON could be *anything* a client sends. The type is an unchecked **assertion** — a lie the compiler trusts. A request with a missing `email` or `name: 123` sails past the type system and crashes (or worse, persists bad data) downstream. The fix is **runtime validation** at the boundary: parse `req.body` with [Zod](/nodejs/zod) (or [Joi](/nodejs/joi)), which both *checks* the real value and *derives* the correct static type — so the type and the runtime reality finally agree. Also note `req.params` values are always `string` (you parse `Number(id)` yourself).
Typing middleware and error handlers

TS
import { Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express'

// Regular middleware — type it as RequestHandler (or annotate the three params):
const logger: RequestHandler = (req, res, next) => {
  console.log(req.method, req.url)
  next()
}

// ERROR middleware MUST have FOUR parameters — that's how Express recognizes it:
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  console.error(err)
  res.status(500).json({ error: 'Internal Server Error' })
}

app.use(logger)
app.use(errorHandler)   // register error handler LAST
Use the `RequestHandler` / `ErrorRequestHandler` types — error handlers need all four `(err, req, res, next)` params
Express provides ready-made function types so you don't annotate three or four parameters by hand. A normal middleware is a **`RequestHandler`** (`(req, res, next)`); an error-handling middleware is an **`ErrorRequestHandler`** (`(err, req, res, next)`). The four-parameter shape isn't cosmetic: Express *detects* error middleware by its arity — a function with four arguments is treated as an error handler, fewer as ordinary middleware — so the `ErrorRequestHandler` type both documents and enforces that contract. Typing handlers this way keeps `next`, `req`, and `res` correctly typed and lets the compiler catch a forgotten `next()` call signature. Register the error handler **last**, after your routes, so thrown/forwarded errors land in it. (In Express 4, errors from `async` handlers must be passed to `next(err)` or caught — they don't propagate automatically.)
Augmenting Request — adding `req.user`

TS
// Auth middleware wants to attach the user — but req has no 'user' property by default.
// Use declaration merging to add it to Express's Request type, app-wide:

// types/express.d.ts
import 'express'
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string }   // now req.user is typed everywhere
    }
  }
}

// Now this is type-safe in any handler:
app.use((req, res, next) => {
  req.user = { id: '42', role: 'admin' }     // ✅ typed, no error
  next()
})
Use declaration merging (`declare global { namespace Express }`) to add custom properties like `req.user` to `Request`
Auth and other middleware commonly **attach data to the request** — `req.user`, `req.requestId`, `req.tenant` — but Express's `Request` type doesn't know about your additions, so `req.user = ...` is a type error out of the box. The clean fix is **declaration merging**: in a `.d.ts` file you re-open Express's `Request` interface (`declare global { namespace Express { interface Request { ... } } }`) and add your properties. TypeScript merges your declaration with the library's, so `req.user` is typed in *every* handler across the app — no casting, no `(req as any).user`. Make the property **optional** (`user?`) since it's only set after the relevant middleware runs, which forces handlers to handle the unauthenticated case. Avoid the lazy alternative of casting to `any` everywhere — it spreads untyped access and defeats the point of using TypeScript at all.
Patterns for end-to-end safety
  • Validate at the boundary — parse req.body/req.query/req.params with Zod; use the inferred type as your handler's body type so type and runtime agree.

  • Type response bodies — set the ResBody generic (or a shared DTO type) so res.json() is checked against what clients expect.

  • Share types between layers — define your domain/DTO types once and reuse them across routes, services, and (ideally) the client.

  • Handle async errors — in Express 4, wrap async handlers (or use a helper / express-async-errors) so rejected promises reach your error middleware.

  • Prefer unknown over any for untyped input, then narrow/validate — it forces a check before use.

  • Keep req.params parsing explicit — they're always strings; convert and validate (Number, date parsing) rather than asserting.

True safety comes from validating input at the edge and reusing shared types — annotations alone only document intent
Typing Express well is less about annotations and more about **where the types come from**. The annotations on `req.body` and `req.params` only *document* intent; real safety arrives when the type is *derived from a runtime check* at the request boundary — validate the input with [Zod](/nodejs/zod), and let its inferred type flow into your handler so what the compiler believes matches what actually arrived. From there, **share DTO/domain types** across routes, services, and even the frontend so a shape change ripples through the whole stack at compile time. Combined with `RequestHandler`/`ErrorRequestHandler` typing, request augmentation for `req.user`, and disciplined async-error handling, you get genuine end-to-end safety — from the moment a request is validated to the typed JSON you send back.
Next
The dev-time runners that execute TypeScript without a manual build: [ts-node & tsx](/nodejs/ts-node-tsx).