Chapter 84 โ Building a Typed Express Server
๐ Definition
Express's type definitions (@types/express) make Request, Response and RequestHandler generic. By filling in those generics, adding a custom error class and a central error handler, you get an API where params, body, query and response bodies are all checked by the compiler.
๐ The Generics on Request
Request<Params, ResBody, ReqBody, ReqQuery>
Response<ResBody>
RequestHandler<Params, ResBody, ReqBody, ReqQuery>| Generic | What it types | Default |
|---|---|---|
Params |
req.params (/users/:id) |
ParamsDictionary (Record<string, string>) |
ResBody |
res.json(...) argument |
any |
ReqBody |
req.body |
any |
ReqQuery |
req.query |
ParsedQs |
req.bodyisanyby default โ this is the biggest hole in an untyped Express app. Pair the generic with runtime validation (see Request Validation below) so the type is actually true.
๐ป Code Example โ A Fully Typed Handler
import type { Request, Response } from "express";
import type { ApiResponse, PublicUser } from "@app/shared";
import { userService } from "./user.service.js";
type Params = { id: string };
export async function getUser(
req: Request<Params>,
res: Response<ApiResponse<PublicUser>>,
): Promise<void> {
const user = await userService.findById(req.params.id); // req.params.id: string
res.json({ success: true, data: user }); // checked against ApiResponse<PublicUser>
// res.json({ success: true, data: user, password: "x" }) โ excess property error
}// Body + query typing
type CreateUserBody = { email: string; name: string; password: string };
type ListQuery = { page?: string; limit?: string; search?: string }; // query values arrive as strings
export async function createUser(
req: Request<{}, ApiResponse<PublicUser>, CreateUserBody>,
res: Response<ApiResponse<PublicUser>>,
) {
const user = await userService.create(req.body);
res.status(201).json({ success: true, data: user });
}
export async function listUsers(
req: Request<{}, unknown, unknown, ListQuery>,
res: Response,
) {
const page = Number(req.query.page ?? 1);
const limit = Math.min(Number(req.query.limit ?? 20), 100);
res.json(await userService.list({ page, limit, search: req.query.search }));
}๐ป Code Example โ Custom Error Class
// utils/AppError.ts
export class AppError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly code: string = "APP_ERROR",
public readonly details?: unknown,
) {
super(message);
this.name = "AppError";
Object.setPrototypeOf(this, new.target.prototype); // keeps instanceof working when targeting ES5
}
static badRequest(msg: string, details?: unknown) { return new AppError(400, msg, "BAD_REQUEST", details); }
static unauthorized(msg = "Unauthorized") { return new AppError(401, msg, "UNAUTHORIZED"); }
static forbidden(msg = "Forbidden") { return new AppError(403, msg, "FORBIDDEN"); }
static notFound(entity = "Resource") { return new AppError(404, `${entity} not found`, "NOT_FOUND"); }
static conflict(msg: string) { return new AppError(409, msg, "CONFLICT"); }
}๐ป Code Example โ Async Errors: Express 4 vs Express 5
// Express 4: a rejected promise in a handler is NOT caught โ unhandled rejection, request hangs.
// Fix with a typed wrapper:
import type { Request, Response, NextFunction, RequestHandler } from "express";
export const asyncHandler =
<P, ResB, ReqB, Q>(
fn: (req: Request<P, ResB, ReqB, Q>, res: Response<ResB>, next: NextFunction) => Promise<unknown>,
): RequestHandler<P, ResB, ReqB, Q> =>
(req, res, next) => {
fn(req, res, next).catch(next);
};
router.get("/:id", asyncHandler(getUser));Express 5 (stable since 2024) forwards rejected promises from handlers to next(err) automatically โ so asyncHandler is no longer required. Interviewers love asking about this difference.
Other Express 5 changes worth knowing: req.query is a getter (read-only), path syntax changed (/*splat instead of /*, no ? optional chars โ use {/:optional}), res.status() only accepts valid codes, and req.body is undefined (not {}) when no body parser ran.
๐ป Code Example โ Central Error Handler
// middleware/error.ts
import type { ErrorRequestHandler, RequestHandler } from "express";
import mongoose from "mongoose";
import { AppError } from "../utils/AppError.js";
import { env } from "../config/env.js";
export const notFound: RequestHandler = (req, _res, next) => {
next(AppError.notFound(`Route ${req.method} ${req.originalUrl}`));
};
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
let status = 500;
let body: { success: false; code: string; message: string; details?: unknown } = {
success: false,
code: "INTERNAL",
message: "Something went wrong",
};
if (err instanceof AppError) {
status = err.statusCode;
body = { success: false, code: err.code, message: err.message, details: err.details };
} else if (err instanceof mongoose.Error.ValidationError) {
status = 400;
body = { success: false, code: "VALIDATION", message: err.message };
} else if (err instanceof mongoose.Error.CastError) {
status = 400;
body = { success: false, code: "BAD_ID", message: `Invalid ${err.path}` };
} else if (typeof err === "object" && err !== null && "code" in err && err.code === 11000) {
status = 409;
body = { success: false, code: "DUPLICATE", message: "Resource already exists" };
}
if (status >= 500) console.error(err); // log real bugs, not 4xx noise
if (env.NODE_ENV !== "production" && status >= 500 && err instanceof Error) {
body.details = err.stack; // never leak stacks in prod
}
res.status(status).json(body);
};Error middleware is recognised by having four parameters โ typing it as ErrorRequestHandler keeps the arity and gives you err: any, which you then narrow.
๐ป Code Example โ Augmenting Request (e.g. req.user)
// src/types/express.d.ts
import type { Role } from "@app/shared";
declare global {
namespace Express {
interface Request {
user?: { id: string; role: Role };
requestId?: string;
}
}
}
export {}; // makes this file a module so `declare global` is allowedMake sure the file is covered by include in tsconfig.json. Now every handler sees req.user without casting.
๐ป Code Example โ Request Validation with express-validator
TypeScript types disappear at runtime, so the server must check every incoming body. express-validator is the most common choice in Express apps; it declares rules as middleware chains.
// middleware/validate.ts
import type { RequestHandler } from "express";
import { validationResult, type ValidationChain } from "express-validator";
import { AppError } from "../utils/AppError.js";
// Runs every rule, then fails with one 400 containing all field errors
export const validate =
(rules: ValidationChain[]): RequestHandler =>
async (req, _res, next) => {
await Promise.all(rules.map((rule) => rule.run(req)));
const result = validationResult(req);
if (result.isEmpty()) return next();
const details = result.array().map((e) => ({
field: e.type === "field" ? e.path : e.type,
message: e.msg as string,
}));
next(AppError.badRequest("Invalid input", details));
};// modules/users/user.validators.ts
import { body, param } from "express-validator";
import { ROLES } from "@app/shared";
export const idParam = [param("id").isMongoId().withMessage("Invalid id")];
export const createUserRules = [
body("email").isEmail().withMessage("Valid email required").normalizeEmail(),
body("name").trim().isLength({ min: 2, max: 80 }),
body("password").isStrongPassword({ minLength: 8, minSymbols: 0 })
.withMessage("Min 8 chars with upper, lower and a number"),
body("role").optional().isIn(ROLES),
];
export const updateUserRules = [
...idParam,
body("name").optional().trim().isLength({ min: 2, max: 80 }),
body("role").optional().isIn(ROLES),
body(["password", "email"]).not().exists().withMessage("Use the dedicated endpoint"),
];Reading only validated fields with matchedData โ anything the client sent that has no rule (like isAdmin: true) is dropped, which blocks mass-assignment attacks:
import { matchedData } from "express-validator";
import type { CreateUserDto } from "@app/shared";
export async function createUser(req: Request, res: Response<ApiResponse<PublicUser>>) {
const dto = matchedData(req, { locations: ["body"] }) as CreateUserDto;
const user = await userService.create(dto);
res.status(201).json({ success: true, data: user });
}Without a library โ a hand-written type guard
For small payloads a type guard is enough, and it shows interviewers you understand that the guard is what makes the type true:
import type { CreateUserDto } from "@app/shared";
export function isCreateUserDto(v: unknown): v is CreateUserDto {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.email === "string" && /^\S+@\S+\.\S+$/.test(o.email) &&
typeof o.name === "string" && o.name.trim().length >= 2 &&
typeof o.password === "string" && o.password.length >= 8
);
}
// in a handler
if (!isCreateUserDto(req.body)) throw AppError.badRequest("Invalid user payload");
req.body.email; // narrowed to string here| Option | Good for |
|---|---|
| express-validator | Express APIs, field-level messages, sanitisers (trim, normalizeEmail) |
| class-validator + DTO classes | NestJS (ValidationPipe) |
| Mongoose schema validators | Last line of defence at the DB layer โ not a replacement for request validation |
| Hand-written type guards | Small payloads, no extra dependency |
๐ป Code Example โ Typed Router
// modules/users/user.routes.ts
import { Router } from "express";
import { requireAuth, requireRole } from "../../middleware/auth.js";
import { validate } from "../../middleware/validate.js";
import { createUserRules, updateUserRules, idParam } from "./user.validators.js";
import * as ctrl from "./user.controller.js";
export const userRouter = Router();
userRouter.get("/", requireAuth, ctrl.listUsers);
userRouter.get("/:id", requireAuth, validate(idParam), ctrl.getUser);
userRouter.post("/", requireAuth, requireRole("admin"), validate(createUserRules), ctrl.createUser);
userRouter.patch("/:id", requireAuth, validate(updateUserRules), ctrl.updateUser);
userRouter.delete("/:id", requireAuth, requireRole("admin"), validate(idParam), ctrl.deleteUser);๐ป Code Example โ Typed Request-ID & Logging Middleware
import { randomUUID } from "node:crypto";
import type { RequestHandler } from "express";
export const requestId: RequestHandler = (req, res, next) => {
req.requestId = req.header("x-request-id") ?? randomUUID();
res.setHeader("x-request-id", req.requestId);
next();
};
export const timing: RequestHandler = (req, res, next) => {
const start = process.hrtime.bigint();
res.on("finish", () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`${req.requestId} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms.toFixed(1)}ms`);
});
next();
};๐ Real-World Impact
- A consistent error envelope
{ success, code, message, details }lets the React client handle every failure with one function. - Typed
res.jsonprevents leaking fields likepasswordโ the compiler rejects them. - Request IDs let you trace one user's failing call across logs from multiple services.
๐ฏ Likely Interview Questions
- How do you type
req.bodyin Express? โ Use theRequest<P, ResBody, ReqBody, Query>generics and validate at runtime (express-validator / type guard); generics alone are just a promise. - What is mass assignment and how do you prevent it? โ Client sends extra fields (
role: "admin") that get written straight to the DB. Whitelist withmatchedData()or explicit DTO mapping. - How do you add
req.userwith TypeScript? โ Global declaration merging onExpress.Request. - How are async errors handled in Express 4 vs 5? โ 4 needs try/catch or an
asyncHandlerwrapper; 5 forwards rejected promises tonextautomatically. - How does Express know a middleware is an error handler? โ It has 4 arguments
(err, req, res, next). - Why a custom
AppErrorclass? โ Distinguishes expected (operational) errors with status codes from unexpected bugs; drives the central handler. - How do you handle Mongo duplicate key errors? โ Detect
code === 11000and return 409. - What's wrong with
res.json(user)straight from Mongoose? โ May leak hidden fields, includes__v,_idvsidmismatch; map to a DTO.
โ Project Setup & Architecture | Index | Next: Mongoose with TypeScript โ