Skip to Content
Course content

102: Handling Errors in Typed Async Code

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of my career staring at production logs, trying to figure out why a promise rejected and why the error message was just [object Object] or, even worse, undefined. In TypeScript, we're great at typing the "happy path"—the data we expect to get back—but we often treat the "unhappy path" as an afterthought. Let's look at why that happens and how we can actually fix it.

The "Unknown" Trap

Let's say we're building a feature to fetch a user's account balance from an API. I'll start with the way most people write this. I've got a UserBalance type, and a function that returns it.

interface UserBalance {
  amount: number;
  currency: string;
}

async function fetchBalance(userId: string): Promise<UserBalance> {
  const response = await fetch(`/api/balance/${userId}`);
  if (!response.ok) {
    throw new Error("Failed to fetch balance");
  }
  return response.json();
}

On the surface, this looks clean. But here is where I usually run into trouble. When I actually call this function, I have to wrap it in a try/catch. Look at what happens to the type of the error:

async function displayBalance(userId: string) {
  try {
    const balance = await fetchBalance(userId);
    console.log(`Your balance is ${balance.amount}`);
  } catch (err) {
    // Wait... what is 'err'?
    console.error(err.message); // ❌ TypeScript Error: 'err' is of type 'unknown'
  }
}

TypeScript is actually doing me a favor here. It's telling me that in JavaScript, anything can be thrown. A string, a number, a custom error object, or even null. Because err is unknown, I can't just dive in and access .message. I'm stuck.

Narrowing the Chaos

My first instinct is usually to just cast it: (err as Error).message. But that's lying to the compiler. If the API throws a string instead of an Error object, my app crashes right there in the error handler. That's a great way to make a bad situation worse.

Instead, I need to prove to TypeScript what the error is. I'll use a type guard. I've found that creating a small helper for this saves me from writing the same if block a hundred times across a project:

function isError(error: unknown): error is Error {
  return error instanceof Error;
}

// Now back in the display function...
} catch (err) {
  if (isError(err)) {
    console.error(err.message); // ✅ TypeScript knows this is an Error
  } else {
    console.error("An unexpected error occurred");
  }
}

This works, and it's safe. But it's tedious. Every single async call in my app now needs this boilerplate. I'm starting to feel like I'm fighting the language rather than using it.

Making Failure a First-Class Citizen

What if we stop treating errors as "exceptions" that blow up the stack and start treating them as expected return values? This is a pattern often called the Result type. Instead of a promise that either resolves to a value or rejects, we return a promise that always resolves to an object telling us if it succeeded or failed.

Let's redefine how we handle the fetch. I'll use a tuple—a fixed-length array—which is a neat trick for keeping things concise:

type AsyncResult<T> = Promise<[T, null] | [null, Error]>;

async function safeFetchBalance(userId: string): AsyncResult<UserBalance> {
  try {
    const response = await fetch(`/api/balance/${userId}`);
    if (!response.ok) throw new Error("Network response was not ok");
    const data = await response.json();
    return [data, null]; // Success path
  } catch (err) {
    return [null, isError(err) ? err : new Error(String(err))]; // Error path
  }
}

Now, look at how the consumption side changes. I don't even need a try/catch block anymore. The type system forces me to check for the error before I can touch the data:

async function displayBalance(userId: string) {
  const [balance, error] = await safeFetchBalance(userId);

  if (error) {
    console.error(`Error: ${error.message}`);
    return;
  }

  // Because I checked 'error' above, TypeScript knows 'balance' is NOT null here.
  console.log(`Your balance is ${balance.amount}`);
}

This is a massive shift in ergonomics. I've moved the error handling from a "catch-all" bucket at the bottom of my function to a deliberate check right where the data arrives. It's more explicit, it's type-safe, and it prevents those annoying "cannot read property of null" crashes in production.




📋 Practical Task

Exercise: The Robust User Wallet Fetcher

You are tasked with creating a safe wrapper for a legacy API that fetches user wallet details. The legacy API is unpredictable and sometimes throws strings instead of Error objects.

Requirements:

  • Define a Wallet interface with id (string) and balance (number).
  • Create a SafeResult<T> type that represents either a success [T, null] or a failure [null, Error].
  • Implement a function fetchWalletSafe(userId: string) that:
    • Calls a mock async function legacyApiFetch(id) (assume this exists and may throw).
    • Catches any error and ensures it is converted into a proper Error object.
    • Returns the SafeResult tuple.
  • Implement a processWalletPayment(userId: string) function that uses fetchWalletSafe. It must check for the error first; if no error exists, it should log the wallet balance.

Tip: Remember to use a type guard or a check to handle the case where the legacy API throws something that isn't an instance of Error.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.