~/blog

A return inside finally silently swallows your exception

published

#javascript#errors#debugging

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 F is a normal completion, set F to B.

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:

CompletionProduced byReplaces the try block’s outcome
normalfalling off the end of the blockno
returnreturnyes
throwthrow, or an uncaught runtime erroryes
breakbreakyes
continuecontinueyes

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

References