Module 7 · Error Handling, Debugging & Testing ⏱ 19 min

try...catch...finally

By the end of this lesson you will be able to:
  • Catch a thrown error with try...catch and read its name and message
  • Use a finally block to guarantee cleanup runs on both the success and failure paths
  • Explain how an uncaught error propagates, and why finally alone cannot stop it

Imagine a program that reads a value a user pasted in and works with it. Most of the time it runs fine. Then someone pastes the word "hello" where a number was expected, and JSON.parse throws — the moment it does, every line after it is skipped and the program crashes with a red stack trace dumped into the console.

That crash is called an exception: a signal that something went wrong mid-statement. Left unchecked, a single bad value takes down everything that runs afterward, including code that had nothing to do with the mistake. try...catch...finally is JavaScript's tool for catching that signal, deciding what to do about it instead of crashing, and still running your cleanup code no matter what happened.

flowchart TD
  T["try block<br/>risky code"] -->|"runs clean"| R["continue after block"]
  T -->|"throws"| C["catch block<br/>handles the error"]
  T --> F["finally block"]
  C --> F
  R --> F
  style T fill:#3776ab,color:#fff
  style C fill:#b45309,color:#fff
  style F fill:#1e7f3d,color:#fff
try wraps risky code. If it throws, catch handles the error; either way, finally always runs.

The three parts

A try block wraps the code that might throw. A catch block runs only if the try threw, and it receives the error object as a parameter — traditionally named e or err. That object carries two things worth reading: e.name, the kind of error (such as TypeError or SyntaxError), and e.message, a short human-readable description of what went wrong.

A finally block runs no matter what: whether try succeeded, whether it threw and catch handled it, even if a return is already in flight. That makes finally the right home for cleanup — resetting a flag, releasing a resource, unlocking something. You may omit catch, or omit finally, but a try must have at least one of them.

JSON.parse throws on bad input. catch grabs the error object; finally always runs. Press Run.
const input = "not json";
try {
  const data = JSON.parse(input);
  console.log("parsed:", data);
} catch (e) {
  console.log("caught a " + e.name);
  console.log("message: " + e.message);
} finally {
  console.log("done");
}

finally runs unconditionally

The single most important fact about finally is that it is unconditional. It runs when try completes normally, when try throws and catch handles it, and when try throws with no catch at all (the error still propagates afterward). Short of the whole runtime being torn down, nothing your code does can make finally skip.

That is exactly the property you want for cleanup. Suppose a function sets a loading = true flag before doing risky work. Whatever happens next — a clean result or a crash — you must set loading = false again afterwards, or the interface hangs forever. Put that reset in finally and it cannot be forgotten on the failure path.

finally resets the flag on both the happy path and the failing path. Press Run.
let loading = true;
function load(value) {
  try {
    if (value === null) {
      throw new Error("no data");
    }
    return "loaded: " + value;
  } catch (e) {
    return "failed: " + e.message;
  } finally {
    loading = false;
  }
}
console.log(load("ok"), "| loading =", loading);
console.log(load(null), "| loading =", loading);
flowchart TD
  T["try throws"] --> Q{"is there a catch?"}
  Q -- Yes --> H["catch handles it"]
  Q -- No --> P["error propagates outward"]
  H --> F["finally still runs"]
  P --> F
  style P fill:#b91c1c,color:#fff
  style F fill:#1e7f3d,color:#fff
  style H fill:#b45309,color:#fff
With no catch, the error propagates outward after finally runs — finally never skips.

Errors that aren't caught keep moving

If a try has no catch — or its catch itself throws — the error does not vanish. It propagates: JavaScript walks back out through every function still on the call stack, hunting for an enclosing try...catch willing to handle it. If none ever does, the error reaches the top level and the program crashes. finally still runs on the way out (that is its job) but it cannot swallow the error by itself — only a catch can.

This is why e.name matters: it tells you the kind of error, so your catch can decide whether to handle it or let it keep propagating to someone better equipped.

risky has finally but no catch. The error passes through finally and out to the outer catch. Press Run.
function risky() {
  try {
    throw new Error("boom");
  } finally {
    console.log("risky's finally ran");
  }
}
try {
  risky();
} catch (e) {
  console.log("outer caught: " + e.message);
}

Keep try blocks narrow

A common beginner instinct is to wrap an entire function in one giant try...catch. Resist that. A handler that swallows everything also swallows the mistakes you did not anticipate — a typo becomes a silent undefined instead of a useful crash, and you spend an hour wondering why a value is missing.

Instead, wrap only the specific call you expect might throw (the JSON.parse, the property access on untrusted data), and let everything you don't expect crash loudly. A loud, specific error you can read is far more valuable than a quiet, handled one you can't trace. Catch narrow, and let the surprising failures surface.

What catch can and can't see

A try...catch catches runtime errors — the kind thrown while your code runs: a property access on undefined, a bad JSON.parse, or a throw you wrote yourself. It will not catch a syntax error (a program that can't even be parsed fails before any try runs), and on its own it does not catch a rejected Promise — those need an await inside the try, or a .catch() on the promise.

That distinction matters once you write asynchronous code. For the synchronous programs in this lesson, every error you can cause is one a try...catch can catch.

flowchart TD
  T["try: return 1"] --> F["finally: return 2"]
  F --> O["caller receives 2"]
  T -.->|"value 1 discarded"| X["lost"]
  style O fill:#b91c1c,color:#fff
  style F fill:#b45309,color:#fff
  style X fill:#6b7280,color:#fff
A return inside finally overrides the return from try. The original value is silently lost.
Exercise

What does this print? Remember: the line after throw never runs.

try {
  console.log("a");
  throw new Error("x");
  console.log("b");
} catch (e) {
  console.log("c");
} finally {
  console.log("d");
}
Exercise

Write safeParse(json) that returns the parsed value, or { error: e.message } if parsing throws. So safeParse('{"a":1}') returns the object { a: 1 }, while a bad string returns an object whose error property holds the message.

function safeParse(json) {
  // return the parsed value, or { error: e.message } if it throws
}
Exercise

This report(level) is meant to return "ok:5" for level 5 and throw when level is negative. Instead it ALWAYS returns "WRONG", because its finally has a return that swallows every other outcome. Remove the return from finally — finally is for cleanup only — so the function behaves correctly.

function report(level) {
  try {
    if (level < 0) throw new Error("bad level");
    return "ok:" + level;
  } finally {
    return "WRONG";
  }
}
Exercise

Write parseAll(strings) that maps an array of JSON strings to their parsed values — but a single bad string must NOT crash the loop. For each string that parses, push the value; for each one that throws, push the string "ERR" instead. So parseAll(['1','oops','3']) returns [1, "ERR", 3].

function parseAll(strings) {
  const results = [];
  // for each string, push the parsed value or "ERR"
  return results;
}
Exercise

Code in a try block throws, but the try has NO catch — only a finally. What happens?

Recap

  • An exception is a thrown error that unwinds the call, skipping every line after the throw.
  • try wraps risky code; catch (e) runs only if it threw and hands you the error object (.name and .message).
  • finally runs unconditionally — success, failure, or a return already in flight. Put cleanup there.
  • With no catch, the error propagates outward after finally; finally alone can't stop it.
  • Catch narrow: handle the specific failure you expect, and let surprises crash loudly.
  • Never return or throw from finally — it silently overrides try's return and can hide the real cause.

Next you'll learn to throw your own meaningful errors, so the rest of your program can catch exactly the failure it expects.

Checkpoint quiz

Inside a try block, what does catch receive as its parameter?

A function has try { return 1; } finally { return 2; }. What does calling it return?

Go deeper — technical resources