Request, response and middleware without any
Express ships with only weakly typed request and response objects out of the box. With generic types for body, params and query, typed middleware chains and a clear error class hierarchy, a fragile JavaScript framework becomes a TypeScript backend architecture that reports errors at compile time instead of at runtime.
Table of contents
- 1. Why Express is weakly typed out of the box
- 2. Typing request and response generically
- 3. Chaining middleware in a type safe way
- 4. Type safe routes with router and param types
- 5. Error handling: error middleware and error classes
- 6. Validating body and query with Zod
- 7. Async handlers without unhandled rejections
- 8. Dependency injection and typed services
- 9. Express compared to Fastify and Hono
- 10. Summary
- 11. FAQ
1. Why Express is weakly typed out of the box
Express itself is a plain JavaScript library. The types come from the separate @types/express package, which describes the signature of handlers, middleware and router methods without constraining the actual contents of body, query or params. In practice this means req.body defaults to any, and anyone using TypeScript with Express initially gets only superficial protection, not real type safety for the payload of a request.
That is not an accident, it is a design decision from a time when Express predated TypeScript by years. Anyone who wants to run Express type safe must actively use the generic parameters of the handler types and cannot rely on the defaults. That is exactly what this article covers: from generic request types through middleware chains to a clean error class hierarchy that surfaces typical runtime mistakes at compile time.
The effort pays off especially in medium sized teams. A type safe Express setup prevents a controller from accessing a field the router never guarantees, or a middleware expecting a field on the request object that was never set. This class of bugs does not disappear through tests alone, it disappears through types the compiler checks on every build.
2. Typing request and response generically
The Express Request type is generic over four parameters: route params, response body, request body and query parameters. Anyone serious about TypeScript with Express defines dedicated types for these four positions per route instead of relying on implicit any. The RequestHandler type accepts the same four generic parameters and makes handler functions typed end to end.
The order of the generic parameters matters: Params, ResBody, ReqBody, ReqQuery. Anyone unfamiliar with this order often puts the response body in the request body slot, which the compiler usually catches, but only after a confusing error message. A recurring Express type safe pattern is therefore to bundle the four types into a dedicated type alias and export that alias per route instead of writing out the generic parameters every time.
// user-routes.types.ts — explicit generic parameters for Express handlers
import type { Request, Response, NextFunction, RequestHandler } from "express";
interface UserParams {
userId: string;
}
interface CreateUserBody {
email: string;
displayName: string;
}
interface UserQuery {
includeArchived?: string;
}
interface UserResponseBody {
id: string;
email: string;
displayName: string;
createdAt: string;
}
// Order matters: Params, ResBody, ReqBody, ReqQuery
export type CreateUserHandler = RequestHandler<
UserParams,
UserResponseBody,
CreateUserBody,
UserQuery
>;
export const createUser: CreateUserHandler = (req, res, next) => {
// req.body is now CreateUserBody, not any
const { email, displayName } = req.body;
// req.params.userId is a typed string, not implicit any
res.status(201).json({
id: req.params.userId,
email,
displayName,
createdAt: new Date().toISOString(),
});
};
A common beginner mistake: developers type only the body, leaving params and query implicitly typed as ParamsDictionary and ParsedQs. That works, but it gives up exactly the part of type safety concerned with URL parameters. A query parameter like includeArchived then remains an arbitrary string-or-array type instead of a clearly defined optional string, forcing unnecessary type assertions later in conditions.
3. Chaining middleware in a type safe way
Middleware in Express often passes information forward through extra fields on the request object, such as an authenticated user set by an auth middleware and read by later handlers. Without TypeScript this is a pure trust relationship: the handler assumes req.user exists, without the compiler ever being able to verify it. Module augmentation can extend the Request interface globally, but that has the downside that the field is considered present everywhere, even where the middleware was never actually run.
The more robust approach for Express type safe code is a dedicated, narrower type per middleware chain: a middleware that enriches a user passes an extended request type to subsequent handlers without changing the global interface. That requires a bit more type work, but it prevents a handler from wrongly assuming a field is always present when the corresponding middleware was never registered on that particular route.
// auth-middleware.ts — narrower request type instead of global augmentation
import type { Request, Response, NextFunction } from "express";
interface AuthUser {
id: string;
role: "admin" | "editor" | "viewer";
}
export interface AuthenticatedRequest extends Request {
user: AuthUser;
}
export function requireAuth(
req: Request,
res: Response,
next: NextFunction
): void {
const token = req.headers.authorization;
if (!token) {
res.status(401).json({ error: "Missing authorization header" });
return;
}
const user: AuthUser = { id: "u_123", role: "editor" };
// Type assertion is safe here because we just constructed the object
(req as AuthenticatedRequest).user = user;
next();
}
// Handler that requires an authenticated request, enforced by the type
export function listOwnItems(
req: AuthenticatedRequest,
res: Response
): void {
res.json({ owner: req.user.id, role: req.user.role, items: [] });
}
4. Type safe routes with router and param types
The Express Router supports the same generic parameters as RequestHandler, but is rarely used with these types in practice. Anyone organizing routes in separate files should export a dedicated router with clearly typed handlers per resource, instead of declaring handler functions loosely and merging them only when registering. The TypeScript pattern for this: define route parameters as their own interface whose field names exactly match the placeholders in the path, for example :userId becomes { userId: string }.
A subtle point with Express: route parameters are always strings at runtime, even when the path looks like a number, for example /users/42. A common mistake is typing the parameter directly as number, which the compiler does not prevent even though the actual value remains a string. The correct solution is to type the parameter as a string and explicitly convert it with Number() or a validation library before using it as a number.
5. Error handling: error middleware and error classes
Express recognizes error middleware by its four parameter signature: (err, req, res, next). TypeScript cannot infer this signature purely from the parameter count, so the ErrorRequestHandler type should be imported explicitly. Without this type it is easy to write a middleware with three instead of four parameters, which Express then never recognizes as an error handler, leading to unhandled errors that silently end up as a generic 500 in production.
For Express type safe error handling, a small hierarchy of error classes inheriting from a shared base class with an HTTP status code pays off. The central error middleware then only needs to check whether the error is an instance of this base class and can return the appropriate status code and message consistently to the client, without every single handler maintaining its own try/catch blocks with status codes.
// http-errors.ts — typed error hierarchy for consistent responses
export abstract class HttpError extends Error {
abstract readonly statusCode: number;
constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
export class NotFoundError extends HttpError {
readonly statusCode = 404;
}
export class ValidationError extends HttpError {
readonly statusCode = 422;
constructor(message: string, public readonly issues: string[]) {
super(message);
}
}
export class UnauthorizedError extends HttpError {
readonly statusCode = 401;
}
// error-middleware.ts
import type { ErrorRequestHandler } from "express";
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (err instanceof HttpError) {
res.status(err.statusCode).json({ error: err.message, name: err.name });
return;
}
console.error("Unhandled error:", err);
res.status(500).json({ error: "Internal Server Error" });
};
6. Validating body and query with Zod
Static types from TypeScript are checked exclusively at compile time. At runtime the server does not know whether an actually incoming request really matches the expected shape, because req.body remains a raw JSON object regardless of what the handler's generic type declares. Zod closes exactly this gap: a schema describes the expected shape at runtime, and z.infer automatically derives the matching TypeScript type from that schema, so runtime validation and static type never drift apart.
For Express, a generic middleware factory is a good fit: it accepts a Zod schema and immediately responds with 422 on invalid data, before the actual handler is even reached. The handler itself can then fully rely on the already validated, correctly typed body without writing its own defensive checks.
// validate.ts — generic Zod validation middleware for Express
import type { RequestHandler } from "express";
import { z, ZodSchema } from "zod";
import { ValidationError } from "./http-errors";
export function validateBody<T extends ZodSchema>(schema: T): RequestHandler {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
next(new ValidationError(
"Request body validation failed",
result.error.issues.map((i) => i.message)
));
return;
}
req.body = result.data;
next();
};
}
const createUserSchema = z.object({
email: z.string().email(),
displayName: z.string().min(2).max(80),
});
// Type is inferred, not duplicated by hand
type CreateUserBody = z.infer<typeof createUserSchema>;
router.post("/users", validateBody(createUserSchema), createUser);
7. Async handlers without unhandled rejections
Express version 4 does not automatically catch errors from asynchronous handlers. If an async function throws an exception or rejects a promise, the error does not land in the error middleware, but as an unhandled promise rejection in the process, which in the worst case can crash the entire Node process. This behavior was only fixed in Express 5, which is why many projects still need an explicit wrapper.
The usual TypeScript wrapper accepts an asynchronous handler function and returns a synchronous function that internally calls .catch(next). Important here: the wrapper must remain generic over the same four parameters as RequestHandler, otherwise the concrete types for body, params and query are lost on every use, and one ends up back at implicit any.
// async-handler.ts — preserves generic types while catching rejections
import type { Request, Response, NextFunction, RequestHandler } from "express";
type AsyncHandler<P = {}, ResBody = unknown, ReqBody = unknown, ReqQuery = {}> =
(
req: Request<P, ResBody, ReqBody, ReqQuery>,
res: Response<ResBody>,
next: NextFunction
) => Promise<void>;
export function asyncHandler<P, ResBody, ReqBody, ReqQuery>(
handler: AsyncHandler<P, ResBody, ReqBody, ReqQuery>
): RequestHandler<P, ResBody, ReqBody, ReqQuery> {
return (req, res, next) => {
handler(req, res, next).catch(next);
};
}
router.get(
"/users/:userId",
asyncHandler<{ userId: string }, UserResponseBody>(async (req, res) => {
const user = await userService.findById(req.params.userId);
if (!user) throw new NotFoundError(`User ${req.params.userId} not found`);
res.json(user);
})
);
8. Dependency injection and typed services
Express itself does not come with a built in dependency injection mechanism, unlike frameworks such as NestJS. For TypeScript with Express, a simple pattern is enough in most projects: services are defined as classes with constructor parameters, instantiated once in the application entry point, and passed to the routers through a factory function instead of importing global singletons.
This manual approach remains sufficient for small to medium sized backends, but loses its advantage as soon as circular dependencies between services appear or mocking becomes cumbersome in tests. In such cases it is worth looking at a dedicated DI framework, which stays optional with Express while being assumed from the start in other TypeScript backend frameworks such as NestJS.
| Criterion | Express | Fastify | Hono |
|---|---|---|---|
| Type inference from schema | Only manual via Zod middleware | Built in via JSON Schema | Manual via Zod validator |
| Async errors safe by default | No (v4), yes from v5 | Yes | Yes |
| Ecosystem size | Very large, many middleware packages | Growing, solid core packages | Small, but Web Standards compatible |
| Edge runtime capability | Node.js only | Node.js, limited edge support | Node, Deno, Bun, Cloudflare Workers |
9. Express compared to Fastify and Hono
Choosing between Express, Fastify and Hono is not purely a matter of taste, it depends heavily on how much built in type safety a team wants directly from the framework. Running Express type safe requires more manual work than Fastify, which brings JSON Schema and type inference out of the box. The big advantage Express retains is its enormous ecosystem of middleware packages that already offers ready made, well tested solutions for many standard tasks.
Teams that have already written a lot of middleware code in Express usually do not benefit from a full migration, but from consistently retrofitting generic types, Zod validation and an error class hierarchy as shown in this article. For new projects without legacy baggage, however, it is worth looking at Fastify or Hono, which were designed with type safety in mind from the ground up.
Mironsoft
TypeScript backends, API architecture and type safety in deployment
Express backend without end to end type safety?
We harden existing Express applications with generic request types, Zod validation and a clean error class hierarchy, so errors surface at compile time instead of with a customer in production.
Type audit
Analysis of all routes for implicit any and missing generic types
Retrofit validation
Zod schemas for body and query, automatically derived types
Error handling
Consistent HTTP error classes instead of scattered try/catch blocks
10. Summary
Making TypeScript with Express type safe primarily means actively using the generic parameters of the handler types instead of relying on the implicit defaults. Request params, response body, request body and query must be defined per route so the compiler catches incorrect accesses before deployment. Middleware chains benefit from narrow, additional request types instead of global module augmentation, and an error class hierarchy replaces scattered status code logic in every single handler.
Zod closes the gap between static type checking and runtime validation by deriving both the runtime check and the TypeScript type from a single schema. An async handler wrapper prevents unhandled promise rejections that would otherwise crash the process in Express 4. Combining these building blocks consistently produces an Express type safe backend that does not quite reach the built in type system of Fastify or Hono, but benefits from the enormous Express ecosystem.
TypeScript with Express: The essentials at a glance
Generic handler types
Specify Params, ResBody, ReqBody and ReqQuery explicitly instead of trusting implicit any.
Middleware types
Narrower request extensions per chain instead of global module augmentation for all routes.
Zod validation
One schema delivers runtime checking and, via z.infer, the matching TypeScript type at once.
Error handling
HttpError hierarchy plus ErrorRequestHandler instead of scattered status code logic in the handler.