A global error handler
The safety net
In the last lesson, we built two tools: response helpers that routes use to return errors directly, and error classes for when errors need to propagate up the call stack. The response helpers handle the expected cases. But what about the unexpected ones?
What happens when a database query throws because the connection dropped? What happens when a service function throws a NotFoundError that the route handler did not anticipate? What happens when a bug causes a TypeError?
These errors propagate out of the route handler. If nothing catches them, the server crashes or returns a raw error to the client. We need a safety net that catches every escaped error and turns it into a proper response.
That is what the global error handler does. It is the single place where all unhandled errors are caught, logged, and converted into HTTP responses.
The global handler
Here is the function. It takes an error and an optional request, and returns a Response:
// src/error-handler.ts
import { AppError } from "./errors.js";
export function handleError(err: unknown, request?: Request): Response {
// Known application errors (thrown by service code)
if (err instanceof AppError) {
const url = request ? new URL(request.url) : null;
console.log(
JSON.stringify({
level: "info",
event: "error",
code: err.code,
message: err.message,
statusCode: err.statusCode,
method: request?.method,
path: url?.pathname,
}),
);
return Response.json(
{ error: { code: err.code, message: err.message } },
{ status: err.statusCode },
);
}
// Unknown errors: these are bugs
const url = request ? new URL(request.url) : null;
console.error(
JSON.stringify({
level: "error",
event: "unhandled_error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
method: request?.method,
path: url?.pathname,
}),
);
// Never expose internal details to the client
return Response.json(
{ error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
{ status: 500 },
);
} Let’s walk through this step by step.
The function takes err: unknown because we do not know what type the error will be. It could be an AppError, a regular Error, or even a string (if someone threw a string, which they should not).
First, it checks if the error is an AppError using instanceof. If it is, we know it is one of our custom errors, so we have access to statusCode, code, and message. These are expected errors. A service function threw a NotFoundError because a record was missing. That is not a bug. We log it at info level and return a response with the correct status code.
If the error is not an AppError, we are in unknown territory. This is a bug: a TypeError, a ReferenceError, a database error, something we did not anticipate. We log everything (including the full stack trace) so the developer can investigate. But we return a generic “An unexpected error occurred” message to the client. We never expose the real error message or stack trace, because those contain internal details.
Wiring it into the app
Hectoday HTTP has an onError callback that catches errors thrown from any route. We wire our handler into it:
// src/app.ts
import { setup, route } from "@hectoday/http";
import { handleError } from "./error-handler.js";
export const app = setup({
onError: ({ error, request }) => {
return handleError(error, request);
},
routes: [
route.get("/health", { resolve: () => Response.json({ status: "ok" }) }),
// ... all routes
],
}); That is it. The onError callback catches any error thrown in any route handler and passes it to handleError. The callback receives three properties: error (the thrown value), request (the original Request object), and locals (any data returned by onRequest). We pass both error and request to handleError so our error logs include the request method and path.
Now here is how the two approaches work together:
import { notFound } from "../errors.js";
import db from "../db.js";
import { chargeCard } from "../services/payment.js";
route.post("/orders", {
request: { body: OrderBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json(
{ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: c.input.issues } },
{ status: 400 },
);
}
const product = db
.prepare("SELECT * FROM products WHERE id = ?")
.get(c.input.body.productId) as any;
if (!product) return notFound("Product");
const total = product.price * c.input.body.quantity;
// This might throw unexpectedly (network error, timeout, etc.)
// The global error handler catches it
const charge = await chargeCard(total, c.input.body.paymentToken);
return Response.json({ orderId: "...", chargeId: charge.chargeId, total }, { status: 201 });
},
}); The route returns errors directly for things it can anticipate: validation failures, missing resources. For unexpected failures (the payment service throwing), the error escapes the route handler and the global onError callback catches it. The user sees a clean 500 response instead of a crash.
[!NOTE] The REST API Design course’s error response format (
{ error: { code, message, details } }) is generated by the response helpers for expected errors and by the global handler for unexpected ones. Every error across every route uses the same format.
What the handler does, summarized
For known errors (AppError subclasses thrown by service code):
- Log at info level (these are expected errors)
- Return a response with the error code, message, and correct status code
For unknown errors (anything else):
- Log the full error with stack trace at error level
- Return a generic “An unexpected error occurred” response with 500
- Never expose the real error message or stack trace to the client
Structured logging
Notice that the error handler logs errors as JSON. This is structured logging:
{
"level": "info",
"event": "error",
"code": "NOT_FOUND",
"message": "Product not found",
"statusCode": 404
} {
"level": "error",
"event": "unhandled_error",
"message": "Cannot read properties of undefined",
"stack": "TypeError: ..."
} Info-level logs are for expected errors (not found, validation failed). These happen all the time and are part of normal operation. Error-level logs are for bugs that need investigation. This distinction matters when you set up monitoring and alerting. You do not want to get paged every time someone requests a product that does not exist.
Exercises
Exercise 1: Implement the global error handler. Throw a NotFoundError from a service function. Verify the response has the correct format and status code.
Exercise 2: Cause a regular Error (not an AppError) to be thrown in a route. Verify the response says “An unexpected error occurred” (not the real message) and the real error appears in the logs.
Exercise 3: Compare returning notFound("Product") directly in a route vs throwing new NotFoundError("Product") from a service function. Both should produce the same response format.
Our error handler treats known and unknown errors differently, but we have not really explained what makes an error “known” vs “unknown.” That distinction is important enough to deserve its own lesson.
Why does the global error handler return a generic message for unknown errors?