The API works, but its errors are stitched together by hand. Three routes (redirect, stats, and delete) each build their own 404 body. Anything unhandled just falls back to whatever Fastify feels like returning. This chapter kicks off Section 5: Hardening & Edge Cases by giving the service one place that owns the error contract. The routes can stop assembling bodies and just throw.

Three pieces go into this:
NotFoundError and ConflictError that a route throws instead of hand-building a response.{ error, message } response from a single place.500 and never leak internal details back to the client.Before writing the code, here are the three ideas this chapter rests on.
Custom error class. A small class you define for one kind of failure. It carries its own HTTP status and label. Instead of copying reply.code(404).send({ error: "Not Found", message }) across routes, a route just writes throw new NotFoundError('No URL found for code "abc"'). The status and shape ride along automatically.
Centralized error handler. A single function, registered once with app.setErrorHandler, that catches every thrown error and turns it into a response. It is the only place that decides what a 404, a 400, or a 500 looks like on the wire. Without it, that decision gets copy-pasted into every route and slowly drifts out of sync.
Leaking internals. This happens when an unexpected crash sends its real error message or stack trace straight back to the client. A stack trace can give away file paths, SQL fragments, or even a database password sitting inside an error string. On a 500, the client should get a generic message while the real error is logged privately on the server. Leaking internals is a real security hole. Someone probing your API should learn that something broke, never what or where.
Right now the 404 body lives in three places. The redirect route writes it, the stats route writes it, and the delete route writes it. That means three copies of the same object and three chances for them to drift apart. If you decide later that the envelope should be { code, detail } instead of { error, message }, you have to edit every route that builds an error. That kind of duplication quietly rots an API from the inside.
Centralizing fixes three problems at once. First, your errors become predictable and documentable. Every error the API returns comes out of one function, so consumers see the same envelope everywhere. A 404, 400, 409, and 500 all share one shape. This makes an error contract something you can actually write down and trust. Second, there is exactly one place to change the shape. If you want a requestId on every error body, you add it to the handler, not to a dozen routes. Third, nothing leaks. An unexpected exception must never spill its message or stack trace to the client. The centralized handler is the single chokepoint where you can guarantee a 500 stays generic on the wire while the real error gets logged somewhere you can find it.
These properties separate a demo from a production service. This is exactly why it opens the hardening section.
Start with the vocabulary. Each error class carries the two things the handler needs to build a response: an HTTP status code and a short error label (the error field, like "Not Found"). This sits on top of the human-readable message it inherits from Error.
typescript// src/errors.tsexport class AppError extends Error {readonly statusCode: number;readonly error: string;constructor(statusCode: number, error: string, message: string) {super(message);this.name = new.target.name;this.statusCode = statusCode;this.error = error;}}export class NotFoundError extends AppError {constructor(message: string) {super(404, "Not Found", message);}}export class ValidationError extends AppError {constructor(message: string) {super(400, "Bad Request", message);}}export class ConflictError extends AppError {constructor(message: string) {super(409, "Conflict", message);}}
AppError is the base class every domain error extends. It stores statusCode and error. It also passes message through to the native Error constructor so error.message keeps working as you would expect. The new.target.name line sets this.name to the actual subclass name ("NotFoundError", not "AppError"). This keeps your stack traces and logs honest.
The subclasses are deliberately tiny. Each one pins down its status and label so the caller writes intent instead of HTTP trivia. You write throw new NotFoundError('...') rather than having to remember that "not found" means 404 and the label is "Not Found". The status and the envelope live in one spot. The route just names the failure.
One thing to flag: ConflictError (409) is defined here, but nothing throws it yet. It is seeded for chapter 19, where two requests can race to generate the same short code and one of them has to lose with a 409. Defining it now means the handler already knows how to map it. In the next chapter, we only have to throw it.
The handler is a single function with three branches, checked in order: a known AppError first, then Fastify's own validation error, and finally everything else.
typescript// src/error-handler.tsimport { FastifyError, FastifyReply, FastifyRequest } from "fastify";import { AppError } from "./errors";export const errorHandler = (error: FastifyError,request: FastifyRequest,reply: FastifyReply): void => {if (error instanceof AppError) {reply.code(error.statusCode).send({error: error.error,message: error.message,});return;}if (error.validation) {reply.code(400).send({error: "Bad Request",message: error.message,});return;}request.log.error(error);reply.code(500).send({error: "Internal Server Error",message: "An unexpected error occurred.",});};
Read it top to bottom. The order is the logic.
The AppError branch comes first. If the thrown error is one of our classes, its statusCode and error already say everything we need. We copy them and the message into the envelope and send it. That one branch handles NotFoundError, ValidationError, ConflictError, and any future subclass without another line of code.
Schema validation comes second. Fastify sets a truthy error.validation when a request fails its JSON Schema (like a missing field or a wrong type). We map that to a 400 with the same envelope. This preserves the exact behavior from chapter 8. It used to sit inline in app.ts, but now it lives here alongside the rest of the error strategy.
Everything else is a 500. If an error is neither one of ours nor a validation error, it is unexpected by definition. We call request.log.error(error) to record the real error message and stack trace in the server logs. Then, we send back a fixed generic body. We never copy error.message or the stack into the response.
That last branch is highly important. The log line gives you the full failure server-side when you need to debug. The response gives the client nothing but "An unexpected error occurred." Someone poking at your API learns only that something broke, not what or where.
We pull errorHandler out into its own module instead of inlining it in app.ts for a practical reason. The test needs to register the exact same function on a throwaway app. This gives us one definition, exercised by both production and the tests.
app.ts has had an inline setErrorHandler since chapter 8, but it only handled the schema-validation 400. We swap that whole block for a one-line registration of the new handler:
typescript// src/app.tsimport { errorHandler } from "./error-handler";// ...const app = Fastify({ logger: opts.logger ?? true });app.setErrorHandler(errorHandler);
The old inline handler covered exactly one case (validation) and let everything else fall through to Fastify's default. The new handler covers all three scenarios: our errors, validation, and the safe 500. The inline version and its now-orphaned FastifyError import are gone.
Check out the start branch and install the dependencies:
bashgit checkout 18-error-handling-startnpm install
The start branch ships the test suite, the real errors.ts, and a placeholder error-handler.ts that just forwards the error to Fastify's default handling (reply.send(error)). The placeholder typechecks and runs fine. It simply does not map anything yet, so the new tests fail on behavior rather than on a missing module.
The new suite spins up its own throwaway Fastify app and registers the same errorHandler. It adds one route per error class, plus a /boom route that throws a leaky Error, and a /schema route that triggers a real validation error.
typescript// __tests__/error-handling.test.tsit("maps NotFoundError to 404 with { error, message }", async () => {const response = await app.inject({ method: "GET", url: "/not-found" });expect(response.statusCode).toBe(404);expect(response.json()).toEqual({error: "Not Found",message: 'No URL found for code "nope"',});});
The AppError cases assert an exact toEqual match on the body. That strictness is what trips the start branch. Fastify's default error serializer tacks on a statusCode field, so the placeholder's response comes back as { error, message, statusCode }. Because of that one extra key, toEqual rejects it.
The most important test is the unexpected-error path. It throws an error whose message contains a fake secret. Then, it checks both that the body is the generic shape and that none of the secret leaked through:
typescript// __tests__/error-handling.test.tsit("maps an unexpected error to a generic 500 that leaks no internals", async () => {const response = await app.inject({ method: "GET", url: "/boom" });expect(response.statusCode).toBe(500);expect(response.json()).toEqual({error: "Internal Server Error",message: "An unexpected error occurred.",});expect(response.body).not.toContain("hunter2");expect(response.body).not.toContain("line 42");expect(response.body).not.toContain("stack");});
On the start branch, the placeholder forwards the raw error. The response message is literally "DB password is hunter2 at line 42". The not.toContain("hunter2") assertion catches the leak red-handed. It is a security test written as a unit test. It fails loudly the instant the handler exposes an internal detail.
Run the unit suite:
bashnpm test

One suite fails: 4 tests fail, 58 pass (62 total). The three AppError cases fail on that extra statusCode field, and the 500 case fails on the leaked secret. The one new test that passes on start is the schema-validation 400. That behavior has existed since chapter 8, and the placeholder forwards error.validation to Fastify's default 400, which happens to line up. Running npm run typecheck stays clean, so the red is purely behavioral.
Switch to the finish branch:
bashgit checkout 18-error-handling-finishnpm install
The finish branch swaps the placeholder for the real errorHandler (shown above) and wires it into app.ts. As the payoff, it refactors the three routes that used to hand-roll a 404 so they throw NotFoundError instead.
Here is the redirect route. It used to build the body inline. Now it just throws:
typescript// src/routes/redirect.tsconst originalUrl = await urlStore.findByCode(code);if (originalUrl === undefined) {throw new NotFoundError(`No URL found for code "${code}"`);}
The stats route follows the same shape:
typescript// src/routes/stats.tsconst record = await urlStore.findRecordByCode(code);if (record === undefined) {throw new NotFoundError(`No URL found for code "${code}"`);}
And the delete route. Notice it still keeps reply around for the 204 success path. Only the miss turns into a throw:
typescript// src/routes/delete.tsconst deleted = await urlStore.delete(code);if (!deleted) {throw new NotFoundError(`No URL found for code "${code}"`);}reply.code(204);return null;
Every one of these routes dropped the same four-line block that set reply.code(404) and returned a literal { error, message } object. The handler owns that shape now.
This is the part worth slowing down on. We just changed three route handlers, and we did not touch a single endpoint test. The redirect, stats, and delete suites still assert response.statusCode === 404 and response.json().error === "Not Found". They still pass, completely unchanged.
They pass because the refactor is behavior-preserving by construction. NotFoundError maps to exactly 404 plus { error: "Not Found", message }. This is the same status, the same error label, and the same message string the routes used to build by hand. The old hand-rolled body and the new mapped body are byte-for-byte identical on the wire, so the assertions never notice anything moved.
This is the goal of a good refactor: hold the observable behavior fixed while you change the internal structure. The errorHandler is the seam here. It is the new internal boundary that lets the routes stop constructing bodies. The pre-existing tests are the safety net proving the seam is wired up correctly. When your tests stay green through a structural change like this, you know you moved the code without moving the behavior.
Run both suites:
bashnpm testnpm run test:integration

Both are green. The unit suite is now 11 suites and 62 tests. The new error-handling.test.ts adds one suite and its five cases. The integration suite holds at 9 suites and 23 tests. The refactor preserved behavior, so nothing there moved. Running npm run typecheck passes with no output.
The error vocabulary is in place, including a ConflictError that nothing throws yet. Next up: collisions and advisory locks. We will turn the short-code generation race into a clean, tested retry-on-conflict outcome.