Chapter 28

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:

  1. catch only captures synchronous errors (a synchronous throw before await in an async function is still caught by the enclosing try)
  2. finally always executes — no exceptions (unless the process crashes or process.exit() is called)
  3. A return/throw inside finally overrides any return/throw from try/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):

  1. Let B be the result of evaluating Block.
  2. Let F be the result of evaluating Finally.
  3. If F.[[Type]] is normal, return Completion(B).
  4. Return Completion(UpdateEmpty(F, B.[[Value]])).

For try…catch…finally:

  1. Let B be the result of evaluating Block.
  2. If B.[[Type]] is throw, then: a. Let C be the result of performing CatchClauseEvaluation with argument B.[[Value]].
  3. Else, let C be B.
  4. Let F be the result of evaluating Finally.
  5. If F.[[Type]] is normal, return Completion(C).
  6. Return Completion(UpdateEmpty(F, C.[[Value]])).

What the spec reveals precisely:

Spec §20.5: Error Objects

Error object creation (new Error(message)) internal steps:

  1. Let O be ? OrdinaryCreateFromConstructor(NewTarget, "%Error.prototype%", « [[ErrorData]] »).
  2. If message is not undefined, then a. Let msgString be ? ToString(message). b. Perform CreateNonEnumerableDataPropertyOrThrow(O, "message", msgString).
  3. Perform ? InstallErrorCause(O, options). (ES2022 Error Cause)
  4. 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

  1. Every JS statement produces a Completion Record with three fields: [[Type]] (normal/return/throw/break/continue), [[Value]], and [[Target]].
  2. finally's Completion Record overrides try/catch results whenever its [[Type]] is not normal — including silently overriding a normal return value.
  3. Synchronous exceptions inside async functions are wrapped as rejected Promises; try/catch with await catches them. Un-awaited rejections escape silently.
  4. Error.stack format is non-standard and engine-specific — never parse it in business logic.
  5. AggregateError (ES2021) carries an array of all failure reasons; it is thrown by Promise.any when all promises reject.
Rate this chapter
4.5  / 5  (3 ratings)

💬 Comments