Welcome to the final lesson of Request Validation and Express Error Architecture! Your API already validates input with Zod, creates request context for every request, and applies JSON requirements only to routes that consume bodies. Now you’ll bring those pieces together under one predictable error architecture.
You’ll create a reusable AppError, add route-specific 405 Method Not Allowed handling, and expand the final Express error middleware so it safely maps application, validation, parser, PostgreSQL, and unexpected failures. You’ll also preserve an important distinction between a matched resource that does not exist and an application path that matched no route at all.
Previously, request context established requestId and startedAt before feature routes ran. Zod errors already reached the final middleware, and Express 5 automatically forwarded rejected asynchronous handlers. This lesson completes that pipeline so routes can describe failures by throwing errors while one final middleware owns the client response format.
Understanding the Final Error Flow
A successful request still returns its normal resource representation:
The final middleware recognizes each known category in a deliberate order. Specific failures are mapped first, while the generic 500 response remains the last fallback.
Why Routes Should Not Format Every Error
Earlier route handlers sometimes wrote an error response directly:
TypeScript
if (!ticket) { response.status(404).json({ error: { code: "TICKET_NOT_FOUND", message: "Ticket not found.", }, }); return;}
This works, but repeating the same formatting across many handlers has drawbacks:
Error envelopes can drift between routes.
Request metadata must be added repeatedly.
New error categories require changes in many files.
Route handlers become crowded with response-formatting details.
The completed design allows the route to describe the failure instead:
TypeScript
if (!ticket) { throw new AppError( 404, "TICKET_NOT_FOUND", "Ticket not found.", );}
Express 5 forwards the thrown error to the final error middleware. The route decides what went wrong, while the centralized handler decides how errors are represented to clients.
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
Defining the Application Error Type
The reusable error class lives in src/lib/errors.ts. It extends JavaScript’s built-in Error while adding the HTTP and API fields required by the application.
export class AppError extends Error { constructor( public readonly statusCode: number, public readonly code: string, message: string, public readonly details?: unknown, ) { super(message); this.name = "AppError"; }}
Extending Error means AppError behaves like a normal JavaScript exception. It can be thrown, passed to next(error), inspected with instanceof, and forwarded through Express’s error pipeline.
statusCode stores the HTTP status the final middleware should send. Examples include 400, 404, 405, and 415.
code is a stable machine-readable identifier such as TICKET_NOT_FOUND or METHOD_NOT_ALLOWED. Client applications can branch on this value without depending on the human-readable message.
message is handled by the parent Error class. It gives developers and API clients a readable explanation of the failure.
details is optional and can include safe structured context. For example, a method error can list the methods allowed by the matched route.
Setting this.name to "AppError" keeps the error recognizable during logging and debugging while preserving normal error behavior.
A route can now create a focused failure with one expression:
throw new AppError( 400, "INVALID_REQUESTER", "requesterId does not reference an existing user.",);
Adding Method Not Allowed Handling
A request can reach a valid route path while using an unsupported HTTP method.
Consider the ticket collection:
GET /api/ticketsPOST /api/tickets
The path exists, but methods such as PUT and DELETE are not supported there. That situation should return 405 Method Not Allowed, not the broader 404 Not Found.
The reusable middleware lives in src/middleware/method-not-allowed.ts.
import type { RequestHandler } from "express";import { AppError } from "../lib/errors.js";export const methodNotAllowed = (allowedMethods: string[]): RequestHandler => (request, response, next) => { response.setHeader( "Allow", allowedMethods.join(", "), ); next( new AppError( 405, "METHOD_NOT_ALLOWED", `${request.method} is not allowed for this route.`, { allowedMethods }, ), ); };
methodNotAllowed() is a middleware factory. Each route supplies its own list of supported methods when configuring the fallback.
The Allow response header communicates the methods accepted by that resource. For ["GET", "POST"], the header becomes Allow: GET, POST.
The attempted method is included in the error message. A PUT request therefore produces a message such as "PUT is not allowed for this route."
allowedMethods is also stored in details, giving clients a structured version of the same information.
The middleware does not write the JSON response itself. It creates an AppError and delegates it through next(error) so the final handler remains the only error formatter.
Why Method Errors Use next(error)
methodNotAllowed() is ordinary synchronous middleware. It deliberately transfers control into Express’s error pipeline:
next( new AppError( 405, "METHOD_NOT_ALLOWED", `${request.method} is not allowed for this route.`, { allowedMethods }, ),);
Calling next() with no argument continues through normal route middleware. Calling next(error) skips the remaining normal handlers and searches for error middleware.
This is different from an async route that throws:
async (_request, response) => { const ticket = await findTicketById("tkt_missing"); if (!ticket) { throw new AppError( 404, "TICKET_NOT_FOUND", "Ticket not found.", ); } response.status(200).json(ticket);}
Because the handler is asynchronous, the thrown error rejects its returned promise. Express 5 automatically forwards that rejection to the final error middleware.
Grouping Collection Methods
The ticket collection is grouped with router.route("/") in src/routes/ticket-routes.ts.
ticketRoutes.route("/") .get( validateRequest({ query: ticketListQuerySchema, }), async (_request, response) => { const { query } = response.locals.validated as { query: TicketListQuery; }; const result = await listTickets(query); response.setHeader( "X-Total-Count", String(result.total), ); response.status(200).json(result.items); }, ) .post( requireJson, validateRequest({ body: createTicketBodySchema, }), async (_request, response) => { const { body } = response.locals.validated as { body: CreateTicketBody; }; if (!(await userExists(body.requesterId))) { throw new AppError( 400, "INVALID_REQUESTER", "requesterId does not reference an existing user.", ); } const ticket = await createTicket(body); response.setHeader( "Location", `/api/tickets/${ticket.id}`, ); response.status(201).json(ticket); }, ) .all(methodNotAllowed(["GET", "POST"]));
.get() and .post() preserve the existing successful collection behavior. Filtering still returns the ticket array and X-Total-Count, while creation still returns 201 and a Location header.
A missing requester is now represented by throwing AppError. Express 5 forwards it because the route handler is asynchronous.
.all() is placed after the supported methods. It catches any other method that reaches the same path.
The fallback advertises exactly GET and POST. It does not guess which methods are supported; the route configuration provides the authoritative list.
Preserving Static Route Order
The static statistics route remains before the dynamic ticket route:
The function checks that the value is a non-null object before reading code.
It returns a string only when the property actually contains one.
Unknown errors without a database code continue toward the generic fallback.
Mapping Foreign-Key Failures
PostgreSQL code 23503 represents a foreign-key violation.
const code = postgresCode(error);if (code === "23503") { response.status(400).json({ error: { code: "REFERENCE_NOT_FOUND", message: "A referenced database record does not exist.", }, meta: buildMeta(response), }); return;}
The client receives HTTP 400 because the requested mutation contains a reference PostgreSQL cannot satisfy.
The response does not expose table names, constraint names, SQL text, or database credentials.
The stable application code is REFERENCE_NOT_FOUND.
Route-level existence checks still provide more specific errors such as INVALID_AUTHOR. This database mapping remains a defensive fallback for foreign-key failures that reach PostgreSQL.
Mapping Uniqueness Conflicts
PostgreSQL code 23505 represents a uniqueness violation.
if (code === "23505") { response.status(409).json({ error: { code: "CONFLICT", message: "The request conflicts with an existing database record.", }, meta: buildMeta(response), }); return;}
HTTP 409 Conflict communicates that the request cannot be completed because it conflicts with existing state.
The client receives a safe general explanation rather than the database’s raw constraint message.
Request context metadata remains available for investigation.
Handling Unexpected Errors
Every unrecognized error reaches the final fallback.
The exact duration can vary, but it should be numeric.
Completing the Course Checkpoint
Use one request ID for a full regression flow:
X-Request-Id: course3-checkpoint
Create a ticket with valid JSON, copy its returned ID, and confirm the successful response still includes:
201 Created
The resource-shaped ticket body
A Location header
The echoed X-Request-Id response header
Patch the new ticket, add a nested comment, list the comment collection, and delete the ticket. Successful responses should remain unchanged by the error refactor.
Then compare:
GET /api/tickets/tkt_missingGET /api/unknown
The first should return TICKET_NOT_FOUND because the ticket route matched. The second should return NOT_FOUND because no route matched.
Recap
You’ve completed the course with one predictable Express 5 failure pipeline. AppError gives routes and middleware a reusable way to describe expected failures through an HTTP status, stable code, readable message, and optional details.
methodNotAllowed() adds correct 405 behavior and Allow headers without formatting responses locally. Ticket and comment routes now throw AppError from async handlers, while requireJson delegates its media-type failure with next(error).
The final error middleware safely distinguishes application errors, Zod failures, malformed JSON, PostgreSQL foreign-key violations, uniqueness conflicts, and unknown errors. Every error includes request metadata, while successful ticket and comment responses remain in their existing resource shapes.
With validation, request context, narrow representation guards, custom application errors, method fallbacks, database mappings, and safe 404 handling now working together, the API has a strong foundation for authentication, authorization, workflows, versioning, readiness, and production-focused testing.