JavaScript for impatient programmers (ES2022 edition)
Please support this book: buy it or donate
(Ad, please don’t block.)

24 Exception handling



This chapter covers how JavaScript handles exceptions.

  Why doesn’t JavaScript throw exceptions more often?

JavaScript didn’t support exceptions until ES3. That explains why they are used sparingly by the language and its standard library.

24.1 Motivation: throwing and catching exceptions

Consider the following code. It reads profiles stored in files into an Array with instances of class Profile:

function readProfiles(filePaths) {
  const profiles = [];
  for (const filePath of filePaths) {
    try {
      const profile = readOneProfile(filePath);
      profiles.push(profile);
    } catch (err) { // (A)
      console.log('Error in: '+filePath, err);
    }
  }
}
function readOneProfile(filePath) {
  const profile = new Profile();
  const file = openFile(filePath);
  // ··· (Read the data in `file` into `profile`)
  return profile;
}
function openFile(filePath) {
  if (!fs.existsSync(filePath)) {
    throw new Error('Could not find file '+filePath); // (B)
  }
  // ··· (Open the file whose path is `filePath`)
}

Let’s examine what happens in line B: An error occurred, but the best place to handle the problem is not the current location, it’s line A. There, we can skip the current file and move on to the next one.

Therefore:

When we throw, the following constructs are active:

readProfiles(···)
  for (const filePath of filePaths)
    try
      readOneProfile(···)
        openFile(···)
          if (!fs.existsSync(filePath))
            throw

One by one, throw exits the nested constructs, until it encounters a try statement. Execution continues in the catch clause of that try statement.

24.2 throw

This is the syntax of the throw statement:

throw «value»;

24.2.1 What values should we throw?

Any value can be thrown in JavaScript. However, it’s best to use instances of Error or a subclass because they support additional features such as stack traces and error chaining (see §24.4 “Error and its subclasses”).

That leaves us with the following options:

24.3 The try statement

The maximal version of the try statement looks as follows:

try {
  «try_statements»
} catch (error) {
  «catch_statements»
} finally {
  «finally_statements»
}

We can combine these clauses as follows:

24.3.1 The try block

The try block can be considered the body of the statement. This is where we execute the regular code.

24.3.2 The catch clause

If an exception reaches the try block, then it is assigned to the parameter of the catch clause and the code in that clause is executed. Next, execution normally continues after the try statement. That may change if:

The following code demonstrates that the value that is thrown in line A is indeed caught in line B.

const errorObject = new Error();
function func() {
  throw errorObject; // (A)
}

try {
  func();
} catch (err) { // (B)
  assert.equal(err, errorObject);
}
24.3.2.1 Omitting the catch binding [ES2019]

We can omit the catch parameter if we are not interested in the value that was thrown:

try {
  // ···
} catch {
  // ···
}

That may occasionally be useful. For example, Node.js has the API function assert.throws(func) that checks whether an error is thrown inside func. It could be implemented as follows.

function throws(func) {
  try {
    func();
  } catch {
    return; // everything OK
  }
  throw new Error('Function didn’t throw an exception!');
}

However, a more complete implementation of this function would have a catch parameter and would, for example, check that its type is as expected.

24.3.3 The finally clause

The code inside the finally clause is always executed at the end of a try statement – no matter what happens in the try block or the catch clause.

Let’s look at a common use case for finally: We have created a resource and want to always destroy it when we are done with it, no matter what happens while working with it. We would implement that as follows:

const resource = createResource();
try {
  // Work with `resource`. Errors may be thrown.
} finally {
  resource.destroy();
}
24.3.3.1 finally is always executed

The finally clause is always executed, even if an error is thrown (line A):

let finallyWasExecuted = false;
assert.throws(
  () => {
    try {
      throw new Error(); // (A)
    } finally {
      finallyWasExecuted = true;
    }
  },
  Error
);
assert.equal(finallyWasExecuted, true);

And even if there is a return statement (line A):

let finallyWasExecuted = false;
function func() {
  try {
    return; // (A)
  } finally {
    finallyWasExecuted = true;
  }
}
func();
assert.equal(finallyWasExecuted, true);

24.4 Error and its subclasses

Error is the common superclass of all built-in error classes.

24.4.1 Class Error

This is what Error’s instance properties and constructor look like:

class Error {
  // Instance properties
  message: string;
  cause?: any; // ES2022
  stack: string; // non-standard but widely supported

  constructor(
    message: string = '',
    options?: ErrorOptions // ES2022
  );
}
interface ErrorOptions {
  cause?: any; // ES2022
}

The constructor has two parameters:

The subsections after the next one explain the instance properties .message, .cause and .stack in more detail.

24.4.1.1 Error.prototype.name

Each built-in error class E has a property E.prototype.name:

> Error.prototype.name
'Error'
> RangeError.prototype.name
'RangeError'

Therefore, there are two ways to get the name of the class of a built-in error object:

> new RangeError().name
'RangeError'
> new RangeError().constructor.name
'RangeError'
24.4.1.2 Error instance property .message

.message contains just the error message:

const err = new Error('Hello!');
assert.equal(String(err), 'Error: Hello!');
assert.equal(err.message, 'Hello!');

If we omit the message then the empty string is used as a default value (inherited from Error.prototype.message):

If we omit the message, it is the empty string:

assert.equal(new Error().message, '');
24.4.1.3 Error instance property .stack

The instance property .stack is not an ECMAScript feature, but it is widely supported by JavaScript engines. It is usually a string, but its exact structure is not standardized and varies between engines.

This is what it looks like on the JavaScript engine V8:

const err = new Error('Hello!');
assert.equal(
err.stack,
`
Error: Hello!
    at file://ch_exception-handling.mjs:1:13
`.trim());
24.4.1.4 Error instance property .cause [ES2022]

The instance property .cause is created via the options object in the second parameter of new Error(). It specifies which other error caused the current one.

const err = new Error('msg', {cause: 'the cause'});
assert.equal(err.cause, 'the cause');

For information on how to use this property see §24.5 “Chaining errors”.

24.4.2 The built-in subclasses of Error

Error has the following subclasses – quoting the ECMAScript specification:

24.4.3 Subclassing Error

Since ECMAScript 2022, the Error constructor accepts two parameters (see previous subsection). Therefore, we have two choices when subclassing it: We can either omit the constructor in our subclass or we can invoke super() like this:

class MyCustomError extends Error {
  constructor(message, options) {
    super(message, options);
    // ···
  }
}

24.5 Chaining errors

24.5.1 Why would we want to chain errors?

Sometimes, we catch errors that are thrown during a more deeply nested function call and would like to attach more information to it:

function readFiles(filePaths) {
  return filePaths.map(
    (filePath) => {
      try {
        const text = readText(filePath);
        const json = JSON.parse(text);
        return processJson(json);
      } catch (error) {
        // (A)
      }
    });
}

The statements inside the try clause may throw all kinds of errors. In most cases, an error won’t be aware of the path of the file that caused it. That‘s why we would like to attach that information in line A.

24.5.2 Chaining errors via error.cause [ES2022]

Since ECMAScript 2022, new Error() lets us specify what caused it:

function readFiles(filePaths) {
  return filePaths.map(
    (filePath) => {
      try {
        // ···
      } catch (error) {
        throw new Error(
          `While processing ${filePath}`,
          {cause: error}
        );
      }
    });
}

24.5.3 An alternative to .cause: a custom error class

The following custom error class supports chaining. It is forward compatible with .cause.

/**
 * An error class that supports error chaining.
 * If there is built-in support for .cause, it uses it.
 * Otherwise, it creates this property itself.
 *
 * @see https://github.com/tc39/proposal-error-cause
 */
class CausedError extends Error {
  constructor(message, options) {
    super(message, options);
    if (
      (isObject(options) && 'cause' in options)
      && !('cause' in this)
    ) {
      // .cause was specified but the superconstructor
      // did not create an instance property.
      const cause = options.cause;
      this.cause = cause;
      if ('stack' in cause) {
        this.stack = this.stack + '\nCAUSE: ' + cause.stack;
      }
    }
  }
}

function isObject(value) {
  return value !== null && typeof value === 'object';
}

  Exercise: Exception handling

exercises/exception-handling/call_function_test.mjs

  Quiz

See quiz app.