Try-catch and error propagation
How errors propagate
In the last lesson, we saw that throw sends an error up the call stack. Let’s look at that in more detail, because understanding propagation is the key to knowing where to put your try-catch blocks.
When an error is thrown, JavaScript walks up the call stack looking for a catch. It checks the current function first. If there is no catch, it moves to the function that called this one. Then to the function that called that one. It keeps going until it either finds a catch block or reaches the top of the stack, at which point the process crashes.
function validateQuantity(qty: number) {
if (qty <= 0) throw new Error("Quantity must be positive");
if (qty > 100) throw new Error("Maximum quantity is 100");
}
function createOrderItem(productId: string, qty: number) {
validateQuantity(qty); // Error thrown here...
// ... rest of function never runs
}
function processOrder(items: any[]) {
for (const item of items) {
createOrderItem(item.productId, item.quantity); // ...propagates here...
}
}
// ...and here, and up, and up, until caught or crash The error from validateQuantity propagates through createOrderItem, through processOrder, and keeps going. Nobody in this chain catches it. If the code that calls processOrder also does not catch it, the server crashes.
So where should we actually catch errors?
Where to catch
The right place to catch depends on what you can do about the error. If you cannot do anything useful, do not catch.
Catch where you can respond to the user. Route handlers are the perfect place because they can return an HTTP response. But here is an important idea: for errors you can anticipate, you do not need try-catch at all. You can just check and return:
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);
if (!product) {
return Response.json(
{ error: { code: "NOT_FOUND", message: "Product not found" } },
{ status: 404 },
);
}
const order = createOrder(c.input.body);
return Response.json(order, { status: 201 });
},
}); No try-catch at all. We check the validation result, check whether the product exists, and return the appropriate response in each case. This is returning errors instead of throwing them. The route handler knows what went wrong and can build a precise response.
Use try-catch for things that might fail unexpectedly. External service calls, file system operations, things where you cannot check ahead of time whether they will work:
async function chargeWithRetry(amount: number, token: string) {
try {
return await chargeCard(amount, token);
} catch (err) {
// Here we can retry the operation
return await chargeCard(amount, token);
}
} We will build a much better retry mechanism later in the course, but the principle is the same. You catch because you have a meaningful action to take.
Do NOT catch where you cannot do anything useful:
// BAD: catching and re-throwing with no added value
function getProduct(id: string) {
try {
return db.prepare("SELECT * FROM products WHERE id = ?").get(id);
} catch (err) {
throw err; // Why catch if you just re-throw?
}
} This catch block does absolutely nothing. It catches the error and immediately throws it again. Remove the try-catch entirely and let the error propagate. The code is simpler and the behavior is identical.
Catching too early
This is one of the most common mistakes. You catch an error deep in your code and return a default value like null. It seems harmless, but it hides the real error:
// BAD: catching too early hides the error
function getProduct(id: string) {
try {
const product = db.prepare("SELECT * FROM products WHERE id = ?").get(id);
if (!product) throw new Error("Product not found");
return product;
} catch (err) {
console.log(err.message);
return null; // Returns null instead of throwing
}
} What do you think happens when the caller tries to use the result?
const product = getProduct("nonexistent");
product.name; // TypeError: Cannot read properties of null The caller gets null, tries to use it, and gets a confusing TypeError. The original error was “Product not found,” which is clear and specific. But the caller never sees that. They see “Cannot read properties of null” and have to figure out where the null came from. You have replaced a clear error with a confusing one.
A better approach is to let the caller decide what to do. Instead of catching in getProduct, just return the result and let the route handler check it:
function getProduct(id: string) {
return db.prepare("SELECT * FROM products WHERE id = ?").get(id);
}
// In the route handler:
const product = getProduct(c.input.body.productId);
if (!product) {
return Response.json(
{ error: { code: "NOT_FOUND", message: "Product not found" } },
{ status: 404 },
);
} The route handler has the context to build a proper error response. The low-level function just does its job.
Catching too late
The opposite problem. No check anywhere, so the error crashes the server:
// BAD: no check anywhere
route.get("/products/:id", {
resolve: (c) => {
const product = db.prepare("SELECT * FROM products WHERE id = ?").get(c.input.params.id) as any;
return Response.json({ name: product.name }); // Crashes if product is undefined
},
}); No check for undefined. If the product does not exist, the server crashes. Every other request being processed at that moment also fails. We saw this in the first lesson.
The right level
The principle is simple: handle errors at the boundary, where your code meets the outside world.
Route handlers are the boundary between your code and the HTTP client. Return proper error responses here.
Service calls are the boundary between your code and external systems. Use try-catch here to retry, fall back, or wrap the error with context.
Everything else should stay simple. Internal functions should return their results and let the caller decide what to do. Do not catch in the middle unless you have a specific action to take.
The finally block
There is one more piece of try-catch we should cover: finally. A finally block runs whether the try block succeeds or throws:
const connection = await getConnection();
try {
await connection.query("INSERT INTO ...");
} catch (err) {
console.error("Query failed:", err.message);
throw err; // Re-throw after logging
} finally {
connection.close(); // Always runs, success or failure
} finally is for cleanup. Closing connections, releasing locks, deleting temporary files. It runs even if the catch block re-throws the error. That makes it the perfect place for cleanup code that absolutely must run no matter what happens.
Exercises
Exercise 1: Write three functions that call each other. Throw an error in the deepest one. Add a catch at different levels and observe how the error propagates.
Exercise 2: Write a function that catches an error too early (returns null). Call it and observe the confusing downstream error.
Exercise 3: Use finally to clean up a resource (close a file handle or database connection) regardless of whether the operation succeeded.
Everything we have covered so far works for synchronous code. But most of the interesting things in a server happen asynchronously. Next, we will see how errors behave differently with promises and async/await.
Where is the right place to catch an error?