TypeScript Exception Handling: try/catch, throw and Async Errors

If you need your app to keep going when a card is declined or an API returns 404, TypeScript exception handling is the answer: throw an Error, catch it with try/catch, then narrow the caught value before you use it.

Most people get stuck on the catch part. In strict mode the caught value is unknown, async errors slip past a try block, and fetch doesn’t throw on a 404 at all.

In this tutorial, I’ll walk you through TypeScript exception handling with five methods, from a plain try/catch/finally to async HTTP errors and a Result type that skips exceptions entirely.

The Sample Checkout Script We’ll Use for Exception Handling

Every example below works on one small US checkout flow. I ran all of them under TypeScript 7.0.2 and Node.js v22.22.2 with --strict, and the outputs come straight from those runs.

  • Order 4821 for $149.99 is the happy path.
  • Order 4822 has a $0.00 total, which should never be charged.
  • ZIP codes 78701 (valid), 7870 (too short) and 99999 (triggers a database error).
  • Orders 4999 and 5000 don’t exist, so the API answers 404.

Method 1: Handle Exceptions With try, catch and finally (Recommended)

Use this whenever a single call can fail and you know what to do next. We charge orders 4821 and 4822 from the sample:

function chargeOrder(id: number, total: number): string {
  if (total <= 0) {
    throw new RangeError(`Order ${id}: total must be above $0.00`);
  }
  return `Order ${id} charged $${total.toFixed(2)}`;
}

const orders: [number, number][] = [[4821, 149.99], [4822, 0]];

for (const [id, total] of orders) {
  try {
    console.log(chargeOrder(id, total));
  } catch (err) {
    if (err instanceof Error) console.log("Failed:", err.message);
  } finally {
    console.log(`Order ${id} checked`);
  }
}

Output:

Order 4821 charged $149.99
Order 4821 checked
Failed: Order 4822: total must be above $0.00
Order 4822 checked
VS Code editor with a TypeScript chargeOrder function that throws a RangeError, terminal showing order 4821 charged, order 4822 failed and both orders checked by finally
Order 4821 is charged, order 4822 fails, and the finally line runs for both orders.

Order 4821 prints charged $149.99, while order 4822 prints the RangeError message. Both orders still print checked. The dollar formatting uses toFixed(2), explained in rounding to 2 decimals in TypeScript.

How does this code work?

throw stops chargeOrder() right away and jumps to the nearest catch.

The catch (err) block checks err instanceof Error before reading .message. Then finally runs no matter what, so it’s the place for cleanup.

If try/catch syntax is new to you, our try/catch in TypeScript basics post covers the block structure step by step.

Important: always throw an Error object, not a string. Only Error objects carry a stack trace and pass an instanceof Error check.

Method 2: Narrow Unknown TypeScript Errors With instanceof

Use this when one try block can fail in several ways. Same checkout data, now with four things that go wrong: bad JSON from the API, a bad toFixed() argument, a missing address and a thrown string:

function describe(err: unknown): string {
  if (err instanceof SyntaxError) return `Bad JSON: ${err.message}`;
  if (err instanceof RangeError) return `Out of range: ${err.message}`;
  if (err instanceof TypeError) return `Wrong type: ${err.message}`;
  if (err instanceof Error) return `${err.name}: ${err.message}`;
  return `Non-Error value thrown: ${String(err)}`;
}

const tasks: (() => unknown)[] = [
  () => JSON.parse("{ total: 149.99 }"),
  () => (149.99).toFixed(120),
  () => (null as unknown as { zip: string }).zip,
  () => { throw "Payment service offline"; },
];

for (const task of tasks) {
  try {
    task();
  } catch (err) {
    console.log(describe(err));
  }
}

Output:

Bad JSON: Expected property name or '}' in JSON at position 2 (line 1 column 3)
Out of range: toFixed() digits argument must be between 0 and 100
Wrong type: Cannot read properties of null (reading 'zip')
Non-Error value thrown: Payment service offline
VS Code running a TypeScript describe function that narrows an unknown catch value with instanceof SyntaxError, RangeError and TypeError, terminal showing four different messages
Each failure lands in its own instanceof branch, and the thrown string falls through to the last line.

The run prints four different lines: a SyntaxError for the JSON, a RangeError for toFixed(120), a TypeError for the null address and the plain string at the end. Our post on undefined and null in TypeScript shows how to stop that TypeError before it happens.

How does this code work?

With strict on, TypeScript types the catch variable as unknown, because JavaScript lets you throw any value. You can’t read .message until you prove it’s an Error.

describe() checks the most specific built-in classes first, then Error, then everything else. Our guide to any vs unknown in TypeScript explains why unknown is the safer default.

You might try to skip the check by typing the catch variable. TypeScript won’t let you:

try {
  JSON.parse("{ total: 149.99 }");
} catch (err: Error) {
  console.log(err.message);
}

Compiler output:

charge_annot.ts(3,15): error TS1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified.

Only any or unknown is allowed there. For catching several classes in one block, see catching error types in TypeScript.

Method 3: Throw Custom Errors and Rethrow What You Can’t Handle

Use this in bigger apps. We’ll use the same ZIP codes from the sample: a ValidationError knows which field is wrong, and anything else gets passed up:

class ValidationError extends Error {
  constructor(message: string, readonly field: string) {
    super(message);
    this.name = "ValidationError";
  }
}

function checkZip(zip: string): void {
  if (!/^\d{5}$/.test(zip)) {
    throw new ValidationError(`"${zip}" is not a 5-digit ZIP`, "zip");
  }
}

function saveAddress(zip: string): void {
  try {
    checkZip(zip);
    if (zip === "99999") throw new Error("Database timeout");
    console.log(`Saved ZIP ${zip}`);
  } catch (err) {
    if (err instanceof ValidationError) {
      console.log(`Fix the ${err.field} field: ${err.message}`);
      return;
    }
    throw err; // not ours to handle: pass it up
  }
}

for (const zip of ["78701", "7870", "99999"]) {
  try {
    saveAddress(zip);
  } catch (err) {
    console.log("Caller got:", (err as Error).message);
  }
}

Output:

Saved ZIP 78701
Fix the zip field: "7870" is not a 5-digit ZIP
Caller got: Database timeout

78701 saves, 7870 asks the user to fix the zip field, and 99999 reaches the caller as Database timeout.

How does this code work?

saveAddress() only handles the error it understands. The throw err line rethrows everything else, so the database timeout isn’t silently swallowed.

Error codes, the cause option and full class hierarchies are in our TypeScript custom error types tutorial.

Pro Tip: you don’t need Object.setPrototypeOf in error classes any more. It was an ES5 workaround, and TypeScript 7 no longer compiles to ES5.

Method 4: Catch HTTP Exceptions in Async TypeScript Code

Here’s the catch: fetch only rejects on network failures. A 404 or 500 response resolves normally, so you have to check response.ok and throw yourself.

Same sample orders as before, now loaded through an API. A local stand-in replaces the server, so the run needs no network:

// Stand-in for the orders API, so the run needs no network
async function api(path: string): Promise<Response> {
  if (path === "/orders/4821") {
    return Response.json({ id: 4821, total: 149.99 });
  }
  return new Response("Not Found", { status: 404 });
}

class HttpError extends Error {
  constructor(readonly status: number, path: string) {
    super(`GET ${path} returned ${status}`);
    this.name = "HttpError";
  }
}

async function getOrder(id: number): Promise<{ total: number }> {
  const path = `/orders/${id}`;
  const res = await api(path);
  if (!res.ok) throw new HttpError(res.status, path);
  return res.json();
}

async function main() {
  for (const id of [4821, 4999]) {
    try {
      const order = await getOrder(id);
      console.log(`Order ${id}: $${order.total.toFixed(2)}`);
    } catch (err) {
      if (err instanceof HttpError && err.status === 404) {
        console.log(`Order ${id} not found (HTTP ${err.status})`);
      } else {
        throw err;
      }
    }
  }
  const all = await Promise.allSettled([getOrder(4821), getOrder(5000)]);
  for (const r of all) {
    console.log(r.status, r.status === "fulfilled"
      ? r.value.total : (r.reason as Error).message);
  }
}

main().catch((err) => console.log("Unhandled:", err));

Output:

Order 4821: $149.99
Order 4999 not found (HTTP 404)
fulfilled 149.99
rejected GET /orders/5000 returned 404
VS Code running TypeScript async code with an HttpError class, terminal showing order 4821 loaded for $149.99, order 4999 not found with HTTP 404 and Promise.allSettled results
Order 4821 loads, order 4999 is reported as a 404, and Promise.allSettled returns one fulfilled and one rejected result.

Order 4821 loads at $149.99 and order 4999 prints not found (HTTP 404). The allSettled lines show fulfilled 149.99 and rejected GET /orders/5000 returned 404.

How does this code work?

getOrder() turns a non-OK status into an HttpError that carries the status code. Inside main(), await makes a rejected promise behave like a thrown error, so a normal try/catch handles it.

Promise.allSettled() is the batch version: it never rejects, and it tells you which calls failed.

Promise chains and .catch() are covered in our TypeScript promises guide. If Node.js says fetch doesn’t exist, read how to fix ReferenceError: fetch is not defined.

Why Doesn’t try/catch Catch My Async Error?

This is the async bug I see most often. Same declined card ($649.99), two functions that look identical except for one word:

async function chargeCard(total: number): Promise<string> {
  if (total > 500) {
    throw new Error(`Card declined for $${total.toFixed(2)}`);
  }
  return "approved";
}

async function withoutAwait(total: number) {
  try {
    return chargeCard(total);       // promise returned, not awaited
  } catch {
    return "handled inside";
  }
}

async function withAwait(total: number) {
  try {
    return await chargeCard(total); // rejection lands in catch
  } catch {
    return "handled inside";
  }
}

async function main() {
  console.log("with await:", await withAwait(649.99));
  try {
    console.log(await withoutAwait(649.99));
  } catch (err) {
    console.log("escaped the try:", (err as Error).message);
  }
}

main();

Output:

with await: handled inside
escaped the try: Card declined for $649.99