Custom error classes
The problem with ad-hoc error responses
In the try-catch lesson, we returned error responses directly from route handlers. That works, but look at what happens when you have multiple routes that all need to handle the same kinds of errors:
// Route 1
if (!product) {
return Response.json(
{ error: { code: "NOT_FOUND", message: "Product not found" } },
{ status: 404 },
);
}
// Route 2
if (!user) {
return Response.json(
{ error: { code: "NOT_FOUND", message: "User not found" } },
{ status: 404 },
);
}
// Route 3
if (!order) {
return Response.json(
{ error: { code: "NOT_FOUND", message: "Order not found" } },
{ status: 404 },
);
} Three routes, same pattern, same structure, same status code. The only difference is the resource name. If you want to change the error format (say, add a timestamp field), you have to update every single place. That is tedious and error-prone.
We need helper functions that build consistent error responses. And we need a way to identify error types so that code further up the chain can decide what to do with them.
Error response helpers
Let’s start with a set of functions that create error responses. Each one knows its own status code and error code:
// src/errors.ts
export function notFound(resource: string): Response {
return Response.json(
{ error: { code: "NOT_FOUND", message: `${resource} not found` } },
{ status: 404 },
);
}
export function validationFailed(issues: { field: string; message: string }[]): Response {
return Response.json(
{ error: { code: "VALIDATION_ERROR", message: "Validation failed", details: issues } },
{ status: 400 },
);
}
export function unauthorized(message: string = "Authentication required"): Response {
return Response.json({ error: { code: "UNAUTHORIZED", message } }, { status: 401 });
}
export function forbidden(message: string = "Insufficient permissions"): Response {
return Response.json({ error: { code: "FORBIDDEN", message } }, { status: 403 });
}
export function conflict(message: string): Response {
return Response.json({ error: { code: "CONFLICT", message } }, { status: 409 });
}
export function rateLimited(retryAfter: number): Response {
return new Response(
JSON.stringify({ error: { code: "RATE_LIMITED", message: "Too many requests" } }),
{
status: 429,
headers: { "Content-Type": "application/json", "Retry-After": String(retryAfter) },
},
);
}
export function serviceUnavailable(service: string): Response {
return Response.json(
{ error: { code: "SERVICE_UNAVAILABLE", message: `${service} is currently unavailable` } },
{ status: 503 },
);
} Let’s walk through what these give us.
Each function is named after the kind of error it represents. notFound takes a resource name (“Product”, “User”, “Order”) and builds the response automatically. The status code (404) and error code ("NOT_FOUND") are baked in. You never have to remember that “not found” maps to 404. The function knows.
validationFailed is a bit special. It takes an array of field-level errors. When a user submits a form with three invalid fields, the response tells them exactly which fields need fixing and why. This is much more useful than a single “Validation failed” message.
rateLimited includes a Retry-After header, telling the client how many seconds to wait before trying again. serviceUnavailable names the service that is down. Each function carries exactly the data it needs.
Using error response helpers
Now let’s see how these work in practice:
import { notFound, validationFailed, conflict } from "../errors.js";
route.get("/products/:id", {
request: { params: z.object({ id: z.string() }) },
resolve: (c) => {
if (!c.input.ok)
return validationFailed(
c.input.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
);
const product = db.prepare("SELECT * FROM products WHERE id = ?").get(c.input.params.id);
if (!product) return notFound("Product");
return Response.json(product);
},
});
route.post("/products", {
request: { body: CreateProductBody },
resolve: (c) => {
if (!c.input.ok) {
return validationFailed(
c.input.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
);
}
const existing = db.prepare("SELECT id FROM products WHERE name = ?").get(c.input.body.name);
if (existing) return conflict("A product with this name already exists");
// ... create product
},
}); Notice how clean the route handlers are. No manual JSON construction. No remembering status codes. Just call the right function and return the result. Every error across every route uses the same format because they all go through the same helpers.
When you still need error classes
The response helpers cover expected errors that routes handle directly. But sometimes errors happen deeper in your code, inside service functions or utility code that does not have access to the HTTP response. For those cases, custom error classes let you carry structured information up the call stack.
Add the error classes to the same src/errors.ts file, below the response helpers:
// src/errors.ts (append below the response helpers)
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
constructor(message: string, statusCode: number, code: string) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, "NOT_FOUND");
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, "CONFLICT");
}
} AppError extends the built-in Error. That means it inherits message, name, and stack. It is a real error object, so it works with try-catch and instanceof.
The constructor takes three parameters. message is the human-readable description, same as a regular Error. statusCode is the HTTP status code (400, 404, 500, etc.). code is a machine-readable string like "NOT_FOUND" that the client can use to branch logic, as the REST API Design course explained.
this.name = this.constructor.name sets the error’s name to the class name. So a NotFoundError will have name: "NotFoundError" instead of name: "Error". This makes logs much easier to read.
These classes are not the primary error handling mechanism. They are a fallback for when code deep in your application hits a problem and cannot return a Response directly. The global error handler (which we will build next) catches these and converts them into proper HTTP responses. But whenever you are inside a route handler and you know what went wrong, prefer returning a response directly.
instanceof for type checking
When you catch an error, you can use instanceof to check which kind it is:
catch (err) {
if (err instanceof NotFoundError) {
// 404 logic
} else if (err instanceof AppError) {
// Any other known error
} else {
// Unknown error, probably a bug
}
} instanceof checks the class hierarchy. A NotFoundError is also an AppError (because it extends it), so err instanceof AppError returns true for all our custom errors. That is why the order matters: check specific classes first, then the base class, then handle unknown errors last.
Exercises
Exercise 1: Create the error response helpers and use them in two different route handlers. Verify both routes return errors in the same format.
Exercise 2: Create the AppError base class and two specific error classes. Verify that instanceof works correctly across the hierarchy.
Exercise 3: Refactor a route that builds error responses manually to use the helper functions instead. Notice how much cleaner it gets.
We now have two tools: response helpers for returning errors directly from routes, and error classes for when errors need to propagate. Next, we will build a global error handler that catches thrown errors and converts them into proper HTTP responses, so nothing slips through the cracks.
Why do we use error response helpers instead of building Response.json calls everywhere?