Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Catching It Doesn't Mean You Handled It

  • software-engineering
  • php
  • typescript

A try/catch that swallows the error and keeps going feels responsible.

You "handled" it. The request didn't blow up. The page still rendered. Ship it.

Then someone asks why the invoice never sent, and your code shrugs because the failure happened in a catch block that decided silence was safer than honesty.

Catching an exception is not the same as handling one.

The empty catch is a lie

try {
    $this->mailer->send($invoice);
} catch (\Throwable $e) {
    // don't break checkout
}

Checkout stayed up. Good. The invoice did not. Also true. You just made the second fact invisible.

No log. No failed job. No status on the order. No signal to a human. The user thinks they are done. Ops thinks nothing is wrong. The bug report arrives three days later as "the system is broken," which is hard to debug when the system successfully forgot.

Handling has a job

A catch block should change something the rest of the system can see.

Retry later. Mark the record failed. Queue a follow-up. Show the user a real message. Re-throw after you add context. Fail the request if the product cannot honestly continue without that step.

"Don't crash" is a constraint. It is not a strategy.

PHP: catch, then decide

Laravel makes it easy to do the boring right thing: report the exception, move the work, leave a breadcrumb on the domain object.

try {
    $this->mailer->send($invoice);
    $order->markInvoiceSent();
} catch (TransportException $e) {
    report($e);
 
    $order->markInvoiceFailed($e->getMessage());
 
    SendInvoiceJob::dispatch($order)->delay(now()->addMinutes(5));
}

The request can still return success for the parts that actually succeeded. The order tells the truth. A worker can try again. report() puts the stack somewhere you will look.

If the mail must succeed before you confirm the order, do not catch-and-continue. Let it fail loudly, or fail into a pending state the UI already knows how to show.

TypeScript: stop turning errors into undefined

Frontend and Node code pick up the same habit with a friendlier costume.

async function loadProfile(userId: string): Promise<Profile | undefined> {
  try {
    return await api.getProfile(userId);
  } catch {
    return undefined;
  }
}

Callers cannot tell "user has no profile" from "network died" from "you are logged out." Everything becomes a missing object, and the UI invents a default that looks fine until it isn't.

Prefer a result the caller has to face:

type ProfileLoad =
  | { ok: true; profile: Profile }
  | { ok: false; reason: "not_found" | "unauthorized" | "unavailable" };
 
async function loadProfile(userId: string): Promise<ProfileLoad> {
  try {
    const profile = await api.getProfile(userId);
    return { ok: true, profile };
  } catch (e) {
    if (isNotFound(e)) return { ok: false, reason: "not_found" };
    if (isUnauthorized(e)) return { ok: false, reason: "unauthorized" };
    console.error("profile load failed", { userId, e });
    return { ok: false, reason: "unavailable" };
  }
}

You still caught it. You also left a decision the UI can render and a log line a human can use.

When catching is correct

Boundary code should catch.

HTTP controllers, queue workers, CLI commands, webhook endpoints. Something has to turn an unexpected exception into a status code, a failed job, or a clean process exit. Frameworks already do a lot of this for you. Use that path.

Library and domain code usually should not. Catching deep in a helper just so a caller never sees a Throwable often means you erased the only useful signal in the stack.

Also fine: catching a specific expected failure (unique constraint, lock timeout, remote 429) and translating it into a named outcome. That is handling. A blanket catch (\Throwable) that continues is usually hiding.

What to put in the catch every time

Ask three questions before you merge:

  1. Who finds out this failed?
  2. What state did we leave behind?
  3. Can we retry without making it worse?

If the answers are "nobody," "inconsistent," and "no idea," you did not handle the error. You buried it.

The habit

Before you write catch, write the outcome you want after the failure: retry, mark failed, tell the user, abort the request, or re-throw with context.

Then implement that. If the only line in the catch is empty, a vague log, or return null, delete the catch and let a higher boundary do real work. Or finish the job.

Catching it keeps the process alive. Handling it keeps the product honest.

← All posts