Error and Completion Record: Spec Semantics of try/catch/finally
A return inside finally overrides a return inside try — that is not a bug; it is the Completion Record substitution semantics defined by the specification. Understand this mechanism and you can explain every "strange" try/catch/finally behavior rather than memorizing special cases.
🔹 Level 1 · What You Need to Know
7 Built-in Error Types
| Error Type | Triggered By | Typical Code |
|---|---|---|
Error |
Generic base class; used for custom errors | throw new Error('msg') |
TypeError |
Type operation errors (null dereference, calling non-function, etc.) | null.property, undefined() |
RangeError |
Numeric value out of legal range | new Array(-1), Number.toFixed(200) |
ReferenceError |
Accessing a non-existent variable | undeclaredVar |
SyntaxError |
Parse-time syntax error | eval('{{') |
URIError |
Illegal argument to encodeURI/decodeURI |
decodeURIComponent('%') |
EvalError |
Related to eval() — rarely seen in modern code |
— |
Added in ES2021: AggregateError (aggregate error, thrown by Promise.any when all promises reject).
Basic try/catch/finally Rules
try {
throw new Error('problem');
} catch (e) {
console.log(e.message); // 'problem'
// Options: handle the error (stop propagation) or rethrow
// throw e;
} finally {
// Always executes, regardless of how try or catch ended
// Even if the above has a return, this still runs
console.log('cleanup');
}
Key rules:
catchonly captures synchronous errors (a synchronous throw beforeawaitin an async function is still caught by the enclosingtry)finallyalways executes — no exceptions (unless the process crashes orprocess.exit()is called)- A
return/throwinsidefinallyoverrides anyreturn/throwfromtry/catch
Reading Error Stack Traces
try {
someFunction();
} catch (e) {
console.error(e.message); // error message
console.error(e.stack); // stack trace (non-standard, engine-dependent format)
console.error(e.name); // 'TypeError', 'RangeError', etc.
}
Custom Error Classes
class NetworkError extends Error {
constructor(message, statusCode) {
super(message); // must call super first
this.name = 'NetworkError'; // fix name (default would be 'Error')
this.statusCode = statusCode;
// Fix stack trace in V8 (optional, V8-specific)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, NetworkError);
}
}
}
try {
throw new NetworkError('Connection refused', 503);
} catch (e) {
if (e instanceof NetworkError) {
console.log(`HTTP ${e.statusCode}: ${e.message}`);
} else {
throw e; // unknown error type — rethrow
}
}
🔸 Level 2 · How It Actually Works
Completion Record: The Universal Container for Statement Results
In the ECMAScript spec, every statement produces a Completion Record when it executes. This is an internal spec type — not a JavaScript object — and developers cannot access it directly.
Completion Record structure:
┌──────────────────────────────────────────────────────┐
│ [[Type]] │
│ normal | return | throw | break | continue │
│ │
│ [[Value]] │
│ The value associated with this completion: │
│ normal: the computed result of the statement │
│ return: the return value │
│ throw: the thrown value (Error object, etc.) │
│ break/continue: empty │
│ │
│ [[Target]] │
│ The target label for break/continue │
│ All other types: empty │
└──────────────────────────────────────────────────────┘
Examples:
// What Completion Record each statement produces (internal semantics):
let x = 5;
// → { type: normal, value: undefined, target: empty }
return 42;
// → { type: return, value: 42, target: empty }
throw new Error();
// → { type: throw, value: Error{}, target: empty }
break myLabel;
// → { type: break, value: empty, target: 'myLabel' }
try/catch/finally Execution Logic (ASCII Flowchart)
┌──────────────────────────────────────────────────────────────────┐
│ try { B } catch(e) { C } finally { F } — Execution Flow │
│ │
│ 1. Evaluate try block B → tryResult │
│ │ │
│ ├── Is tryResult.[[Type]] === 'throw'? │
│ │ │ Yes → bind e = tryResult.[[Value]] │
│ │ │ Evaluate catch block C → catchResult │
│ │ │ tryResult ← catchResult │
│ │ │ No → tryResult unchanged │
│ │ │
│ 2. Evaluate finally block F → finallyResult │
│ │ │
│ ├── Is finallyResult.[[Type]] === normal? │
│ │ │ Yes → return tryResult (finally doesn't override) │
│ │ │ No → return finallyResult (finally wins!) │
│ │ │
│ Done │
└──────────────────────────────────────────────────────────────────┘
The critical rule: finally's Completion Record overrides all previous results unless its [[Type]] is normal. Any return, throw, break, or continue from finally completely replaces whatever came before.
Concrete Scenario Demonstrations
Scenario 1: finally's return overrides try's return
function f() {
try {
return 1; // Completion: {type: return, value: 1}
} finally {
return 2; // Completion: {type: return, value: 2}
// finally return overrides try return
}
}
console.log(f()); // 2, not 1
Scenario 2: finally's throw overrides a successfully-handled catch
function g() {
try {
throw new Error('original');
} catch (e) {
console.log('caught:', e.message); // prints 'caught: original'
return 'handled'; // Completion: {type: return, value: 'handled'}
} finally {
throw new Error('from finally'); // Completion: {type: throw}
// Overrides catch's return!
}
}
try {
g();
} catch (e) {
console.log('outer caught:', e.message); // 'outer caught: from finally'
}
Scenario 3: finally's break overrides normal control flow
function h() {
outer: try {
return 'from try';
} finally {
break outer; // syntactic trap!
// break jumps out of the try/finally block
}
return 'after try'; // this line IS reached
}
console.log(h()); // 'after try'
// (finally's break aborted the try's return)
This pattern is syntactically legal (even in strict mode) but is universally considered terrible style.
Completion Record Propagation: UpdateEmpty
When the last statement in a block produces a normal Completion with value = empty, the spec uses UpdateEmpty to fill in the previous value:
UpdateEmpty(completionRecord, value):
1. If completionRecord.[[Value]] is not empty, return completionRecord.
2. Return Completion { [[Type]]: completionRecord.[[Type]],
[[Value]]: value,
[[Target]]: completionRecord.[[Target]] }
This explains why try {} finally {} as a whole produces the last value of the try or finally block (when finally is normal).
Promise and try/catch Interaction
// async functions wrap synchronous throws as rejected Promises
async function caller() {
try {
const result = await syncThrowingFunction();
} catch (e) {
// This DOES catch synchronous throws from syncThrowingFunction
// because async wraps them into rejected Promises
console.log('caught:', e.message);
}
}
// The real trap: not awaiting a rejection
async function main2() {
const promise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('late')), 100);
});
return 'done'; // try/catch scope ends here; 100ms later rejection is unhandled
}
Key: Synchronous exceptions inside async functions are wrapped as rejected Promises, so try/catch with await catches them. But rejections from un-awaited Promises are not caught by any surrounding try/catch.
🔺 Level 3 · How the Spec Defines It
Spec §6.2.4: Completion Record
From ECMAScript 2024 §6.2.4:
6.2.4 The Completion Record Specification Type
The Completion type is a Record used to explain the runtime propagation of values and control flow such as the behaviour of statements (break, continue, return, and throw) that perform nonlocal transfers of control.
Values of the Completion type are Record values whose fields are defined as:
Field Name Value Meaning [[Type]] normal, break, continue, return, or throw The type of completion that occurred [[Value]] any ECMAScript language value or empty The value that was produced [[Target]] a String or empty The target label for directed control transfers
The spec introduces shorthand:
- A normal completion containing value v is
NormalCompletion(v).- A throw completion containing value v is
ThrowCompletion(v).- An abrupt completion is any completion with [[Type]] other than normal.
Spec §14.15: The try Statement
Runtime Semantics: Evaluation for try…finally (no catch):
- Let B be the result of evaluating Block.
- Let F be the result of evaluating Finally.
- If F.[[Type]] is normal, return Completion(B).
- Return Completion(UpdateEmpty(F, B.[[Value]])).
For try…catch…finally:
- Let B be the result of evaluating Block.
- If B.[[Type]] is throw, then: a. Let C be the result of performing CatchClauseEvaluation with argument B.[[Value]].
- Else, let C be B.
- Let F be the result of evaluating Finally.
- If F.[[Type]] is normal, return Completion(C).
- Return Completion(UpdateEmpty(F, C.[[Value]])).
What the spec reveals precisely:
- Step 5: Only when finally is
normaldoes the try/catch result (C) win. - Step 6: Whenever finally is not
normal(i.e., has return/throw/break/continue), finally's result wins unconditionally. UpdateEmpty(F, C.[[Value]])handles value threading for break/continue in finally.
Spec §20.5: Error Objects
Error object creation (new Error(message)) internal steps:
- Let O be ? OrdinaryCreateFromConstructor(NewTarget, "%Error.prototype%", « [[ErrorData]] »).
- If message is not undefined, then a. Let msgString be ? ToString(message). b. Perform CreateNonEnumerableDataPropertyOrThrow(O, "message", msgString).
- Perform ? InstallErrorCause(O, options). (ES2022 Error Cause)
- Return O.
Error.captureStackTrace (V8-specific, non-standard): The spec does not define the format of Error.stack. Each engine implements it differently, meaning any code that parses the stack string is non-portable.
AggregateError (ES2021)
AggregateError spec (§20.5.7):
- Inherits from Error
- Has an additional [[Errors]] internal slot
- Constructor: new AggregateError(errors, message, options)
- errors property: array of all errors (created as a data property on the instance)
Trigger scenario: Promise.any(promises) creates AggregateError with all rejection reasons when every Promise rejects:
try {
await Promise.any([
Promise.reject(new Error('err1')),
Promise.reject(new Error('err2')),
Promise.reject(new Error('err3')),
]);
} catch (e) {
console.log(e instanceof AggregateError); // true
console.log(e.errors.length); // 3
console.log(e.errors[0].message); // 'err1'
}
💎 Level 4 · Edge Cases and Traps
Trap 1: finally's return Silently Overrides try's return
function getUser2(id) {
try {
return fetchUserSync(id); // returns a user object
} finally {
return null; // silently overrides! callers always get null
}
}
// No error thrown, no warning — just silently returns the wrong value.
Real bug scenario: A developer writes return in finally to ensure a "clean-up value" is returned, not realizing it overrides the normal return value from try. ESLint's no-unsafe-finally rule specifically detects this pattern.
Trap 2: finally's throw Overrides an Already-Handled Exception
class ResourceManager {
async doWork() {
try {
await this.resource.process();
} catch (e) {
logger.error('Processing failed:', e);
return { success: false, error: e.message }; // error is handled
} finally {
await this.resource.close(); // what if close() rejects?
// If it does, it overrides the catch's return!
}
}
}
// Correct approach: wrap finally cleanup in its own try/catch
async doWork() {
try {
await this.resource.process();
return { success: true };
} catch (e) {
logger.error('Processing failed:', e);
return { success: false, error: e.message };
} finally {
try {
await this.resource.close();
} catch (closeError) {
logger.error('Close failed:', closeError);
// Do NOT rethrow — would override the outer return
}
}
}
Trap 3: async/await and the Boundary with Synchronous try/catch
// async functions wrap synchronous throws — try/catch DOES catch them:
async function main() {
try {
const result = await syncThrowingFunction(); // throws synchronously
} catch (e) {
console.log('caught'); // YES, this runs
}
}
// The real trap: un-awaited Promise rejections escape try/catch
async function main2() {
const promise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('late rejection')), 100);
});
// Not awaiting — try/catch scope ends immediately
return 'done';
}
main2().catch(e => console.log('main2 error')); // NEVER fires
// 100ms later: unhandled rejection
Trap 4: Error.stack Format Is Not Reliable
// Different engines produce different stack formats:
// V8 (Chrome/Node.js):
// Error: something went wrong
// at Object.<anonymous> (/path/to/file.js:10:5)
// at Module._compile (node:internal/modules/cjs/loader:1356:14)
// Firefox:
// fn@/path/to/file.js:10:5
// @/path/to/file.js:20:1
// Safari:
// fn@file:///path/to/file.js:10:5
// Wrong: parsing stack to extract caller information
function getCallerName(error) {
const lines = error.stack.split('\n');
return lines[2].match(/at (\w+)/)?.[1]; // only works in V8!
}
// Correct: use stack only for logging, never for logic
try {
doSomething();
} catch (e) {
logger.error({ message: e.message, stack: e.stack });
// Do NOT parse the stack string
}
Trap 5: Accessing Individual Errors in AggregateError
async function tryMultipleSources(urls) {
try {
const result = await Promise.any(
urls.map(url => fetch(url).then(r => r.json()))
);
return result;
} catch (e) {
if (e instanceof AggregateError) {
const reasons = e.errors.map(err => ({
type: err.constructor.name,
message: err.message,
}));
console.log('All sources failed:', reasons);
// Determine if all failures share a type
const allNetworkErrors = e.errors.every(err => err instanceof TypeError);
if (allNetworkErrors) {
throw new Error('Network unavailable'); // aggregate into a clearer error
}
}
throw e; // non-AggregateError — rethrow
}
}
// Note: the errors array in AggregateError preserves the original Promise
// input order, not the order in which they rejected.
Summary
- Every JS statement produces a Completion Record with three fields:
[[Type]](normal/return/throw/break/continue),[[Value]], and[[Target]]. finally's Completion Record overridestry/catchresults whenever its[[Type]]is notnormal— including silently overriding a normal return value.- Synchronous exceptions inside
asyncfunctions are wrapped as rejected Promises;try/catchwithawaitcatches them. Un-awaited rejections escape silently. Error.stackformat is non-standard and engine-specific — never parse it in business logic.AggregateError(ES2021) carries an array of all failure reasons; it is thrown byPromise.anywhen all promises reject.