Error Handling from First Principles
A guide for developers who throw everywhere and catch nowhere. Every example uses @hectoday/http, but the ideas apply to any server.
Two kinds of errors
Every error in a server falls into one of two categories:
Expected errors — things you know can go wrong. The user sends bad data. The requested resource doesn't exist. The auth token is expired. These aren't bugs. They're normal parts of how your API works.
Unexpected errors — things you didn't anticipate. The database connection dropped. A null appeared where it shouldn't. A third-party API returned something unparseable. These are bugs or infrastructure failures.
The distinction matters because they need different treatment. Expected errors are your responsibility to handle explicitly. Unexpected errors are a safety net problem.
Expected errors: return, don't throw
When something expected goes wrong, return a Response. Don't throw an error.
route.get("/users/:id", {
request: { params: z.object({ id: z.string().uuid() }) },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const user = await db.users.get(c.input.params.id);
if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(user);
},
});Read this top to bottom. Every place the request can end is visible. Bad input? 400. Not found? 404. Success? 200. No hidden jumps. No wondering where execution went.
Now compare with throwing:
// Don't do this
resolve: async (c) => {
if (!c.input.ok) {
throw new BadRequestError(c.input.issues);
}
const user = await db.users.get(c.input.params.id);
if (!user) {
throw new NotFoundError("User not found");
}
return Response.json(user);
};Where do those errors go? Some error handler somewhere. Which one? How is it formatted? What status code does BadRequestError map to? You can't tell from reading the handler. The decision happens elsewhere, invisibly.
Throwing for expected errors is hidden control flow. The handler looks like it does one thing, but execution jumps to a catch block you can't see. Early returns keep every decision in one place.
The early return pattern
Every handler follows the same shape:
resolve: async (c) => {
// Check 1: is the input valid?
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
// Check 2: is the caller authenticated?
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
// Check 3: does the resource exist?
const user = await db.users.get(c.input.params.id);
if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}
// Check 4: is the caller authorized?
if (user.orgId !== caller.orgId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Everything passed. Do the work.
return Response.json(user);
};Each check is two lines: a condition and a return. The happy path flows down. Failure exits early. You can read the handler from top to bottom and know every possible outcome.
Unexpected errors: the safety net
Unexpected errors are different. You didn't plan for them. The database threw a connection error. A JSON.parse failed on corrupted data. A third-party SDK threw something weird.
These should not be handled in every handler. That would mean wrapping every handler in try/catch with identical error responses. Instead, use onError:
const app = setup({
routes: [...],
onError: ({ error, request, locals }) => {
console.error({
error: String(error),
stack: error instanceof Error ? error.stack : undefined,
method: request.method,
path: new URL(request.url).pathname,
requestId: locals.requestId,
});
return Response.json(
{ error: "Internal server error" },
{ status: 500 },
);
},
});If any handler throws an unhandled error, onError catches it, logs it, and returns a clean 500 response. The client gets a generic error. Your logs get the details.
This is the safety net. It exists so you don't have to think about unexpected errors in every handler. Handle what you expect. Let the safety net catch the rest.
Why not try/catch in handlers?
You can. But it usually means you're catching expected errors with a mechanism designed for unexpected ones.
// Don't do this
resolve: async (c) => {
try {
const user = await db.users.get(id);
return Response.json(user);
} catch (error) {
if (error.code === "NOT_FOUND") {
return Response.json({ error: "Not found" }, { status: 404 });
}
throw error; // re-throw unexpected errors
}
};The try/catch hides the "not found" case inside an exception handler. The if inside the catch maps error codes to responses. This is a translation layer that shouldn't exist. If you know the user might not exist, check for it:
// Do this instead
resolve: async (c) => {
const user = await db.users.get(id);
if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(user);
};There's one exception: when a library throws for expected cases and you can't avoid it. Some database clients throw on "not found" instead of returning null. In that case, catch narrowly:
resolve: async (c) => {
let user;
try {
user = await db.users.getOrThrow(id);
} catch {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(user);
};Catch as close to the source as possible. Convert the throw into a value. Then continue with normal control flow.
Error response format
Pick a format and stick with it across your entire API. The simplest one that works:
// Single error
Response.json({ error: "Not found" }, { status: 404 });
// Validation errors (from Zod)
Response.json({ error: c.input.issues }, { status: 400 });
// With a code for programmatic handling
Response.json({ error: "User not found", code: "USER_NOT_FOUND" }, { status: 404 });Clients need two things: a human-readable message and a machine-readable status code. The code field is optional but helpful when the same status code can mean different things.
Don't invent a complex error schema. Don't nest errors in { errors: [{ field, message, code }] } unless you have a real reason. Start simple. Add structure when you need it.
Composing error handling with auth
Auth functions follow the same pattern. They return a value or a Response:
function authenticate(request: Request): User | Response {
const header = request.headers.get("authorization");
if (!header?.startsWith("Bearer ")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const user = verifyToken(header.slice(7));
if (!user) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
return user;
}The error response is decided inside the auth function, right next to the check. Not in a middleware error handler. Not in a global catch block. Right here, where the decision makes sense.
In the handler:
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
// caller is UserTwo lines. The error case is handled. The happy path continues. The handler reads like a story: authenticate, check input, get data, return.
Partial locals in onError
When onRequest throws, onError still runs. But the locals object might be incomplete because onRequest didn't finish:
const app = setup({
onRequest: ({ request }) => ({
requestId: crypto.randomUUID(),
startTime: Date.now(),
}),
routes: [...],
// locals is Partial<{ requestId: string; startTime: number }>
onError: ({ error, request, locals }) => {
console.error({
error: String(error),
requestId: locals.requestId, // might be undefined
});
return Response.json({ error: "Internal server error" }, { status: 500 });
},
});TypeScript types locals as Partial<TLocals> in onError for this reason. Check before using.
Errors from onResponse
If onResponse itself throws, the framework sends the original response as-is. There's no second-level error handler. Keep onResponse simple and unlikely to fail.
Don't leak internals
Never send stack traces, database errors, or internal details to the client:
// Don't do this
onError: ({ error }) => {
return Response.json(
{
error: String(error),
stack: error instanceof Error ? error.stack : undefined,
},
{ status: 500 },
);
};The client gets "error: Cannot read property 'id' of undefined" and your internal code structure. Log the details. Send a generic message:
// Do this
onError: ({ error, request, locals }) => {
// Full details to your logs
console.error({ error, requestId: locals.requestId });
// Generic message to the client
return Response.json({ error: "Internal server error" }, { status: 500 });
};In development, you might want verbose errors. Use an environment check:
onError: ({ error, request, locals }) => {
console.error({ error, requestId: locals.requestId });
const body =
process.env.NODE_ENV === "development"
? { error: String(error), stack: error instanceof Error ? error.stack : undefined }
: { error: "Internal server error" };
return Response.json(body, { status: 500 });
};The complete pattern
Every well-structured handler looks like this:
resolve: async (c) => {
// 1. Validate input
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
// 2. Authenticate
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
// 3. Fetch data
const resource = await db.get(c.input.params.id);
if (!resource) {
return Response.json({ error: "Not found" }, { status: 404 });
}
// 4. Authorize
if (resource.ownerId !== caller.id) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// 5. Do the work
const result = await db.update(resource.id, c.input.body);
// 6. Return success
return Response.json(result);
};Expected errors are early returns with explicit status codes. Unexpected errors fall through to onError. The handler reads top to bottom. Every outcome is visible.
Summary
| Concept | How to handle it |
|---|---|
| Bad input | if (!c.input.ok) → return 400 |
| Not found | if (!resource) → return 404 |
| Unauthenticated | authenticate() returns Response → return it |
| Unauthorized | Check permissions → return 403 |
| Database crash | Don't catch it. onError handles it → 500 |
| Third-party failure | Don't catch it. onError handles it → 500 |
| Expected error from a library that throws | Catch narrowly, convert to a return |
Expected errors: return explicitly. Unexpected errors: let the safety net catch them. Don't throw for flow control. Don't try/catch for expected cases. Keep every decision visible in the handler.