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
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.
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.
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
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.
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
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");
}
throwjumps straight to catch, so the second console.log inside try is skipped.catch runs, then finally always runs.
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
}
Wrap JSON.parse(json) in a try...catch.
On the happy path return the parsed value; in the catch return { error: e.message }.
function safeParse(json) {
try {
return JSON.parse(json);
} catch (e) {
return { error: e.message };
}
}
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";
}
}
finally always runs after try — and a return inside it overrides try's return.
Delete the return "WRONG" line entirely. An empty finally is valid; finally should only clean up.
function report(level) {
try {
if (level < 0) throw new Error("bad level");
return "ok:" + level;
} finally {
// cleanup only — no return, no throw
}
}
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;
}
Put try...catch INSIDE the loop, so one bad string does not end the whole loop.
On success push JSON.parse(s); in the catch push the string "ERR".
function parseAll(strings) {
const results = [];
for (const s of strings) {
try {
results.push(JSON.parse(s));
} catch (e) {
results.push("ERR");
}
}
return results;
}
Code in a try block throws, but the try has NO catch — only a finally. What happens?
finally always runs, but it cannot swallow an error on its own. With no catch, the error propagates outward after finally finishes — until an enclosing try...catch handles it, or it crashes the program.
Recap
- An exception is a thrown error that unwinds the call, skipping every line after the throw.
trywraps risky code;catch (e)runs only if it threw and hands you the error object (.nameand.message).finallyruns 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.