PGH Web

PGH Web

Web Solutions from Pittsburgh

Error Handling in TypeScript That Is Actually Usable

Jeff Straney·

I was debugging a production issue where a background job was silently succeeding according to our job runner but not actually completing the work. The job had a try/catch that caught errors and logged them, except the logging call itself had a subtle bug and was silently swallowing the caught error. The outer try/catch caught the error from the logging call and did nothing with it. The job reported success.

When I found it, the error handling code was four levels deep, each level catching what it could and making a best-effort attempt to do something reasonable. The actual error was a database constraint violation that would have been immediately obvious if any layer had propagated it correctly. Instead it had been caught, wrapped, caught again, logged incorrectly, swallowed, and the job had reported success.

The problem wasn't that we had error handling. The problem was that none of it was typed, so no layer knew what errors to expect, and every layer guessed.

The problem with catch (e: any)

TypeScript 4.0 added the useUnknownInCatchVariables compiler option, which makes caught errors unknown instead of any. It's strict mode behavior now. The reason is simple: you have no idea what type the thrown value is. It could be an Error. It could be a string. It could be any object. Code that assumes e.message exists on every caught error is wrong, and the compiler has no way to tell you that unless the type reflects the uncertainty.

The result of catch (e: any) being the default pattern is that every catch block either assumes the shape of the error (brittle) or does nothing useful with the error information (useless). When errors propagate through multiple layers, each layer is guessing.

The Result type approach

The pattern that makes error handling composable is returning errors as values rather than throwing them.

type Result<T, E = Error> = 
  | { success: true; value: T }
  | { success: false; error: E };

A function that can fail returns Result<T, E> instead of throwing. The caller gets a typed discriminated union and has to handle both cases before the code compiles.

type DatabaseError = {
  code: "CONNECTION_FAILED" | "CONSTRAINT_VIOLATION" | "NOT_FOUND";
  message: string;
};

async function getUser(id: number): Promise<Result<User, DatabaseError>> {
  try {
    const user = await db.users.findById(id);
    if (!user) {
      return { success: false, error: { code: "NOT_FOUND", message: `User ${id} not found` } };
    }
    return { success: true, value: user };
  } catch (e) {
    return { success: false, error: { code: "CONNECTION_FAILED", message: String(e) } };
  }
}

// The caller has to handle both cases
const result = await getUser(42);
if (!result.success) {
  if (result.error.code === "NOT_FOUND") {
    return notFound();
  }
  return serverError(result.error.message);
}

const user = result.value; // typed correctly here

The caller knows what errors are possible because the function signature says so. The compiler enforces that both cases are handled before result.value is accessible.

When to still use throw

throw is still the right answer for truly unexpected errors: programmer errors, assertion failures, conditions that represent a bug rather than an expected failure mode.

The distinction is: is this an error the caller can reasonably handle, or is this a bug? A missing user is handleable. A database constraint violation in an update operation is handleable. An undefined where a required argument was expected is a bug. A corrupt internal state is a bug.

Errors the caller can handle should be return values. Bugs should throw and propagate, because the right response to a bug is crashing, logging, and alerting, not catching and trying to continue in an unknown state.

Async boundaries

The place this gets complicated is async code. Unhandled promise rejections are a real failure mode: the operation failed, nobody noticed, the system continued as if it succeeded.

// This swallows the error if the promise rejects
processPayment(orderId).then(() => console.log("done"));

// This handles it
processPayment(orderId)
  .then(() => console.log("done"))
  .catch((e) => {
    logger.error("payment processing failed", { orderId, error: String(e) });
  });

With the Result pattern, this gets cleaner because the error case is in the normal control flow rather than an exception to it:

const result = await processPayment(orderId);
if (!result.success) {
  logger.error("payment processing failed", { orderId, error: result.error });
  return;
}

Making it composable

The friction with the Result pattern is threading it through multiple layers without each layer having to explicitly unwrap and rewrap. A simple helper reduces some of that:

function map<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => U
): Result<U, E> {
  if (!result.success) return result;
  return { success: true, value: fn(result.value) };
}

With this, you can transform successful results without touching the error case:

const userResult = await getUser(42);
const nameResult = map(userResult, (user) => user.name);

This is not monadic programming for its own sake. It's a pragmatic response to the fact that error paths get tedious when every step requires an explicit check. The goal is making the error handling easy enough that it actually gets written, rather than being replaced by catch (e: any) and a hope that nothing goes wrong.

The job processor I started with had typed errors in the rewrite. Each layer declared what it could return. No layer could swallow an error without the compiler knowing, because the error was in the return type. The category of silent failure disappeared. That is not a guarantee that the code is correct, but it is a guarantee that at least the error handling has to be deliberate.