Files
What you'll learn
- Write error-handling middleware with four parameters, mount it last, and answer every error in one shape
- Throw from synchronous and async handlers alike, relying on Express 5 to route both to the error handler
- Answer unmatched requests with a 404 middleware, and keep internal details out of 500 responses
Errors
Every route so far has answered its own errors: a 404 here, a 400 there, each written out where it happens. That spreads the error format across the whole app, and it covers only the errors someone thought of. A database that goes away, a typo that throws, a body that is not JSON: those reach Express with no route ready for them, and Express answers with a page of its own. This lesson moves error handling to one place, so that routes can simply throw and every error leaves the app in the same shape.
What Express does with an error
Anything that throws inside a handler, or is passed to next(err), skips every remaining ordinary middleware and route and goes to error-handling middleware. If there is none, Express's built-in handler answers. Running in production mode, as it does here, that handler sends a small HTML page saying Internal Server Error and logs the stack to the console; a thrown error with a status property of 4xx gets that status and its standard phrase instead.
Express 5 catches async errors. A rejected promise from an async handler goes to the error handling exactly like a synchronous throw. In Express 4 it did not: the rejection was unhandled, the request hung, and every async route needed a try/catch or a wrapper. In Express 5, throw inside async (req, res) => { … } just works.
Error-handling middleware
It is middleware with four parameters:
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ error: 'Something went wrong' });
});
Express tells it apart from ordinary middleware by counting its parameters, so all four must be declared, even an unused next. It belongs last, after every route, because Express only reaches it by skipping forward. If a response has already started when an error arrives (res.headersSent), the handler cannot change its status any more; passing the error on with next(err) lets Express close the connection.
Errors that carry their status
A route that finds nothing wants to say "404" without knowing how the app formats errors. An error class that carries the status does it:
class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
app.get('/owners/:id', (req, res) => {
const owner = findOwner(req.params.id);
if (!owner) throw new HttpError(404, 'No such owner');
res.json(owner);
});
The error handler checks err instanceof HttpError and answers err.status with err.message. NestJS's HttpException is this same idea, built in.
Errors from middleware
Middleware fails too, and passes its errors on the same way. express.json() receiving broken JSON does not throw into your route; the route never runs. It calls next(err) with an error whose status is 400 and whose type is 'entity.parse.failed', which the error handler can recognise and answer in the API's own shape.
404: the request nothing matched
A request that no route answers is not an error to Express; it simply reaches the end of the chain, and Express answers Cannot GET /nope in HTML. Ordinary middleware mounted after all the routes catches those requests instead, since nothing before it answered them. A path that exists with a method no route declares ends up there too.
What a 500 must not say
An unexpected error's message is written for developers: a database address, a file path, a query. Sending it to the client hands that to anyone who can trigger the error. The client gets a generic message; the details go to the log, where console.error puts them.
Your task
- Write
HttpError: anErrorthat also carries the status to answer with. GET /cats/:idthrowsHttpError(404, "Cat <id> not found")for a missing cat.POST /catsthrowsHttpError(422, "name must be a non-empty string")unless the name is a string with something other than spaces.- After the routes, answer anything unmatched with
404and{ "error": "No route for <METHOD> <path>" }. - Last, an error handler: an
HttpErroranswers its status and{ "error": <message> }; a body that is not valid JSON answers400and{ "error": "The body is not valid JSON" }; anything else is logged and answers500and{ "error": "Internal server error" }.
When it fails
- Errors still come back as HTML: the error handler has three parameters, so Express treats it as ordinary middleware.
- The error handler never runs: it is mounted before the routes.
- "The database fails" contains
database unavailable: the 500 senterr.message. /nopeanswersCannot GET /nope: the 404 middleware is missing, or is before the routes and answers everything.
Remember
- Anything thrown, or passed to
next(err), goes to error-handling middleware; Express 5 includes async handlers. - Error middleware has four parameters and goes last; a 404 middleware goes after the routes.
- An error class with a status lets routes throw instead of answering.
- A 500 says nothing specific to the client; the log gets the details.
Stuck? Show a hint
class HttpError extends Error with a status. Error middleware is (err, req, res, next) => { … }: all four parameters, or Express treats it as ordinary middleware; mount it after everything else. The 404 handler is ordinary middleware after the routes. express.json() reports a body it could not parse as an error whose type is 'entity.parse.failed'. Log an unexpected error with console.error, and answer only a generic message.
Press Run tests to start the app. Its log appears here.Graded endpoints
Nothing wrong yet
The route threw an HttpError; the error handler turned it into the status and message it carries
express.json() failed before the route ran, and passed its error on: 400, as JSON rather than Express's HTML page
Thrown from an async handler: Express 5 catches it all the same
An error nobody planned for: 500, and nothing about the database reaches the client
201, as before
The failures stored nothing, and Milo got the next id
The catch-all after the routes answers in the API's own shape
A path that exists with a method no route has is a request nothing matched too