A return inside finally silently swallows your exception
published
TL;DR
If a finally block completes abruptly, its completion replaces the one from try/catch. A return in finally therefore discards a pending exception and a pending return value. The call succeeds with the wrong value and the error is gone, with no warning at runtime and none from TypeScript. Enable no-unsafe-finally and keep finally to side effects only.
The problem
This function cannot throw:
function readConfig() {
try {
throw new Error('ENOENT: config.json missing');
} finally {
return { port: 3000 };
}
}
readConfig(); // → { port: 3000 }
No catch, no logging, no rethrow. The Error object is constructed, thrown, and then dropped on the floor because the finally block returned. Callers see a perfectly ordinary config object built from a file that does not exist.
The same mechanism overwrites a value, not just an error:
function pick() {
try {
return 'a';
} finally {
return 'b';
}
}
pick(); // → 'b'
In real code this rarely looks so obvious. It shows up when someone shortens a cleanup block, usually in an async resource wrapper:
async function withClient(pool, fn) {
const client = await pool.connect();
try {
return await fn(client);
} finally {
return client.release(); // the bug
}
}
client.release() returns undefined, so every call to withClient now resolves to undefined, and any error thrown by fn is discarded. The wrapper looks like it is doing careful cleanup. It is doing careful cleanup and destroying the result.
break and continue do the same thing inside a loop:
function parse(rows) {
const out = [];
for (const row of rows) {
try {
out.push(JSON.parse(row));
} finally {
continue; // any SyntaxError from JSON.parse is discarded
}
}
return out;
}
parse(['{"ok":1}', 'not json']); // → [ { ok: 1 } ]
Why it happens
This is specified behaviour, not an engine quirk. In the evaluation semantics for try Block Finally in ECMA-262, the engine evaluates the finally block into a completion record F, and then:
If
Fis a normal completion, setFtoB.
B is the completion of the try (or catch) block. So the only case where the try block’s outcome survives is when finally finishes normally. If finally completes abruptly, F stays as it is and B is discarded entirely, whether B was a return value or a throw.
Completions come in five kinds, and four of them are abrupt:
| Completion | Produced by | Replaces the try block’s outcome |
|---|---|---|
| normal | falling off the end of the block | no |
| return | return | yes |
| throw | throw, or an uncaught runtime error | yes |
| break | break | yes |
| continue | continue | yes |
That table is the whole bug. Anything in the bottom four rows, written directly in a finally block, wins.
What to do
Keep finally to side effects. Close the handle, release the client, clear the timer. No return, no throw, no break, no continue.
async function withClient(pool, fn) {
const client = await pool.connect();
try {
return await fn(client);
} finally {
await client.release(); // await it, do not return it
}
}
Turn on the lint rule. no-unsafe-finally flags exactly these four statements when they appear directly in a finally block, and it is part of the recommended config in @eslint/js:
// eslint.config.js
import js from '@eslint/js';
export default [
js.configs.recommended, // includes no-unsafe-finally
];
Transform the result outside the try. If you need to change what the function returns, do it after the cleanup has run:
function readConfig() {
let raw;
try {
raw = loadFile('config.json');
} finally {
releaseLock();
}
return { port: 3000, ...JSON.parse(raw) };
}
To genuinely suppress an error, say so. A catch block that swallows deliberately is readable and greppable; a finally that swallows accidentally is neither.
try {
await flush();
} catch (err) {
logger.warn({ err }, 'flush failed, continuing');
}
Caveats
- The rule allows indirect usage. A
returninside a function or class defined within thefinallyblock belongs to that inner function and is not flagged, correctly, because it never completes thefinallyblock itself. - TypeScript does not report this. The types are consistent: the function really can return
{ port: number }, so there is nothing for the checker to complain about. - Code coverage will not save you either. Every line in the examples above executes; the error path is taken and then abandoned.
- Turning the rule on in an existing codebase can surface real intent. Some
finally { return }blocks were written on purpose years ago, and the fix is to convert them into an explicitcatch, not to add a disable comment. - None of this is about
awaitinfinally. Awaiting cleanup is fine and often necessary. Returning it is the problem.