Errors, validation, and defensive code · Lesson 44 of 77 · concept
try, catch, finally
About 55 minutes.
By the end of this lesson you can
- Catch an error so the program continues, and say exactly which lines were skipped.
- Use finally for cleanup that must happen whether the work succeeded or failed.
- Re-throw an error you cannot handle instead of swallowing it.
- Explain why an empty catch block is more dangerous than no catch block at all.
The shape of it
Explanation
try takes a block of code that might throw. catch takes a name for whatever was thrown and a block to run instead of the rest of the try block. finally takes a block that runs either way. You need try plus at least one of the other two; all three together is common and all three are independent.
The name in catch is optional. If the block never looks at what was thrown you can write catch with no parentheses at all, and that is more honest than declaring a variable you never read.
One try statement can wrap one line or two hundred. Wrapping one is nearly always better: the smaller the try block, the more precisely you know what the catch block is apologising for.
Catching rewinds nothing
A mental model
This is the misunderstanding worth killing early. Catching an error does not undo anything. It is not a transaction, and there is no rollback.
Everything the try block managed to do before it threw has still been done. Variables it set are still set. Items it pushed onto an array are still on the array. A message it already sent has been sent. What catch gives you is control of what happens next, and nothing else.
So the question a catch block has to answer is not can I undo this but what is true now, and what should I do about it. Sometimes that means cleaning up by hand, which is what finally is for.
finally runs on every way out
Explanation
finally runs when the try block finishes normally. It runs after a catch block handles an error. It runs when the try block returns, before the caller receives the value. It runs when an error is passing through on its way out with no catch block at all.
That is what makes it the right home for cleanup: closing something you opened, releasing something you locked, stopping a spinner, counting an attempt. Written once in finally, it happens on paths you have not thought of yet, including the ones somebody adds next year.
The ordering is worth saying out loud, because it surprises people: a return inside try does not leave immediately. The value is computed, then finally runs, and only then does the caller see the value.
A return inside finally eats the result
A common mistake
Because finally runs on the way out, a return inside it replaces whatever was on its way out. A function whose try block returns "the answer" and whose finally block returns "the cleanup" returns "the cleanup".
The same applies to errors: a return inside finally will discard an error that was passing through, so the caller sees a perfectly ordinary return value where there should have been a failure. That is a silent failure created by a line of cleanup code, and it is very hard to find.
The rule that avoids all of this: never return, throw, break or continue from inside a finally block. Do cleanup there and nothing else.
The empty catch block
Why it matters
catch (error) {} is a sentence in English. It says: I know this can fail, I have thought about it, and continuing as though it had not is the correct behaviour.
Almost every empty catch block in the world is a lie. It was written to make a red message go away during development and it stayed. The failure it hides does not go away; it becomes a bug report months later that nobody can reproduce, because the one place that knew what happened chose not to say.
A useful discipline: if you write an empty catch block, write the comment that justifies it on the same screen. If you cannot write the comment, you do not want the empty catch block — you want a log line, a fallback with a stated reason, or no catch at all.
Re-throwing what you cannot fix
Explanation
A catch block does not have to handle everything it receives. It can handle the one kind of failure it understands and throw the rest onwards with a bare throw error, which passes the original object along untouched: same name, same message, same stack.
This is what turns catch from a net into a filter. A function that knows how to cope with a missing file has no idea what to do about a bug in its own arithmetic, and pretending otherwise turns the bug into a wrong answer.
If you want to add context on the way past, throw a new error with cause set to the original, as in lesson two. What you must not do is throw a new error without the cause, because then the sentence you added has replaced the information somebody actually needed.
catch receives whatever was thrown
A common mistake
A catch block catches every value thrown anywhere inside its try block. That includes values thrown by code you did not write, values that are not errors at all, and — the one people forget — errors thrown by your own guard clauses a few lines above.
That last case bites hard. You validate an input inside a try block and throw a careful TypeError; two lines later your own catch block turns it into a default value, and the validation you were so pleased with has no effect whatsoever.
So a catch block that does anything other than report should test what it caught. error instanceof SyntaxError, or a check on error.name, turns an indiscriminate net back into a deliberate decision.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
The order things actually run in
The same function called twice, once succeeding and once failing, with a log line in each of the three blocks. Read the seven output lines against the code until the order is obvious.
1function readTotal(order) {2 try {3 console.log("1. reading");4 return order.total;5 } catch (error) {6 console.log("2. only on failure: " + error.name);7 return 0;8 } finally {9 console.log("3. always");10 }11}12 13console.log(readTotal({ total: 40 }));14console.log(readTotal(null));- line 4
On the successful call this returns 40 — but look at the output: line 9 still runs before the caller prints anything.
- line 6
Only reached on the failing call. Reading .total of null throws a TypeError, which lands here.
- line 9
Runs on both calls, after the try block or the catch block has already decided what to return.
- line 14
Passing null is the failure. Note that the program keeps going and prints 0: the error was handled, not merely observed.
What it prints
1. reading3. always401. reading2. only on failure: TypeError3. always0
The finally block that ate the answer
Two nearly identical functions. One does cleanup in finally; the other returns from it. Only one of them still works.
1function careful() {2 try {3 return "the answer";4 } finally {5 console.log("cleaning up");6 }7}8 9function careless() {10 try {11 return "the answer";12 } finally {13 return "the cleanup";14 }15}16 17console.log(careful());18console.log(careless());- line 5
Cleanup only. The value from the try block is untouched, and the log line appears before the caller prints the returned value.
- line 13
A return here replaces the one on line 11. No warning, no error — the function simply returns the wrong thing forever.
- line 13
The same line would silently discard an error passing through. That is why the rule is: cleanup in finally, nothing else.
What it prints
cleaning upthe answerthe cleanup
Catching one kind and passing the rest on
One try block, two very different failures. The catch block absorbs the expected one and re-throws the other, which is the difference between a filter and a net.
1function countFrom(text) {2 try {3 const value = JSON.parse(text);4 if (typeof value !== "number") {5 throw new TypeError("countFrom: expected a number, received " + JSON.stringify(value));6 }7 return value;8 } catch (error) {9 if (error instanceof SyntaxError) {10 return 0;11 }12 throw error;13 }14}15 16console.log(countFrom("42"));17console.log(countFrom("not json"));18try {19 countFrom('"42"');20} catch (error) {21 console.log(error.name + ": " + error.message);22}- line 5
This throw is inside the try block, so it lands in the catch block below. Your own errors are caught by your own nets.
- line 9
The only failure this function claims to understand: text that was never JSON. Anything else is somebody else's problem.
- line 12
Re-throwing the original object. Delete this line and the TypeError on line 5 becomes a count of 0 — the guard would be decorative.
- line 19
Valid JSON, wrong shape. The quotes make it the string "42", so the guard fires and the TypeError escapes the function.
What it prints
420TypeError: countFrom: expected a number, received "42"
What an empty catch block costs
Three messages go in, two come out, and nothing anywhere says that one was lost. This is what silent failure looks like from the outside.
1const inbox = [];2 3function addMessage(raw) {4 try {5 inbox.push(JSON.parse(raw).subject);6 } catch (error) {}7}8 9addMessage('{"subject":"hello"}');10addMessage("{broken");11addMessage('{"subject":"goodbye"}');12 13console.log(inbox.length);14console.log(inbox.join(", "));- line 6
The empty catch. It claims that a message failing to parse is fine and expected — a claim nobody made deliberately.
- line 10
This message is dropped. The function returns normally, the caller carries on, and no count anywhere reflects the loss.
- line 13
Two, not three. To notice this you have to already know how many messages there should have been, which is exactly the knowledge the program had and threw away.
What it prints
2hello, goodbye
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
All three blocks run in this one. Write down the four output lines, in order, before revealing them.
function step() { try { console.log("a"); throw new Error("boom"); } catch (error) { console.log("b"); return "from catch"; } finally { console.log("c"); }}console.log(step());
What is actually wrong with writing catch (error) {} around a risky call?
Inside a try block you validate an argument and throw your own TypeError. The same try block also calls JSON.parse. What does a plain catch block with no checks receive?
Write it yourself
Exercise · core · 12 tests
Settings that survive a broken file
An application reads its settings from text that may have come from anywhere. Write readSettings(text) so that a broken settings file never stops the program. - Parse text as JSON. If it parses to a usable settings object — an object that is not null and not an array — return it unchanged. - If it does not parse, or parses to something that is not a usable settings object, return the defaults: theme "light" and fontSize 14. - Whatever happens, count the attempt. Declare readAttempts at the top level starting at 0, and add one on every call, including the calls that fail. Do the counting in a finally block, so there is exactly one line in the function that does it.
readSettings never throws, for any argument. It returns the parsed object itself when the text is JSON describing an object, and a fresh defaults object with theme "light" and fontSize 14 for everything else — including the JSON texts null, [1,2], 7 and true. readAttempts increases by exactly one per call, whichever path was taken.
Define readSettings and readAttempts at the top level of your code — the tests call them by name.
Exercise · stretch · 11 tests
Re-throw what you cannot fix
Write parseCount(text), which reads a count out of a piece of JSON text. - If text is not valid JSON, that is an ordinary, expected situation: return 0. - If it is valid JSON but the value is not a finite number, that is a mistake by whoever produced the text: throw a TypeError whose message starts with "parseCount: " and contains the value as JSON. - Otherwise return the number. Here is the trap. The TypeError you throw is thrown from inside the same try block that JSON.parse is in, so a catch block that handles everything will swallow your own error and return 0. Catch only SyntaxError, and re-throw anything else.
parseCount returns the number for valid JSON numbers, and 0 for text that is not JSON at all. For valid JSON that is not a finite number — a string, null, true, an object, an array — it throws a TypeError that escapes the function. It never returns a non-number, and it never turns its own TypeError into 0.
Define parseCount at the top level of your code — the tests call it by name.
Worth remembering
- When does a finally block run?
On every way out of the try statement: after the try block finishes, after a catch block handles an error, and while a return or an error is on its way out. A return inside finally replaces whatever was leaving, which is why cleanup belongs there and control flow does not.
- Why should a catch block test what it caught?
One catch block receives every value thrown inside its try block, including errors your own guard clauses threw. Handling only the kind you understand and re-throwing the rest stops a handler from silently absorbing a real bug.
- What does catching an error undo?
Nothing. Everything the try block did before it threw has still been done. catch gives you control of what happens next, not a rollback — which is why cleanup has to be written by hand, usually in finally.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: That the catch binding is optional, that the catch block runs for any value thrown in the try block, and that the finally block runs after try and catch regardless of whether an error was thrown — including that a return in finally overrides one from try or catch.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown