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.
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:
throw
statement to indicate that there was a problem.
try-catch
statement to handle the problem.
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.
throw
This is the syntax of the throw
statement:
throw «value»;
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 “The superclass of all built-in exception classes: Error
” (§26.4)).
That leaves us with the following options:
Using class Error
directly. That is less limiting in JavaScript than in a more static language because we can add our own properties to instances:
const err = new Error('Could not find the file');
err.filePath = filePath;
throw err;
Using one of the subclasses of Error
such as TypeError
or RangeError
.
Subclassing Error
(more details are explained later):
class MyError extends Error {
}
function func() {
throw new MyError('Problem!');
}
assert.throws(
() => func(),
MyError
);
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:
try-catch
try-finally
try-catch-finally
try
blockThe try
block can be considered the body of the statement. This is where we execute the regular code.
catch
clause
If an exception is thrown somewhere inside the try
block (which may happen deeply nested inside the tree of function/method calls) then execution switches to the catch
clause where the parameter refers to the exception. After that, execution normally continues after the try
statement. That may change if:
return
, break
, or throw
inside the catch
block.
finally
clause (which is always executed before the try
statement ends).
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);
}
catch
binding ES2019We 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.
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();
}
finally
is always executedThe 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);
Exercise: Exception handling
exercises/exception-handling/call_function_test.mjs
Error
This is what Error
’s instance properties and constructor look like:
class Error {
// Actually a prototype data property
get name(): string {
return 'Error';
}
// Instance properties
message: string;
cause?: unknown; // ES2022
stack: string; // non-standard but widely supported
constructor(
message: string = '',
options?: ErrorOptions // ES2022
) {}
}
interface ErrorOptions {
cause?: unknown; // ES2022
}
The constructor has two parameters:
message
specifies an error message.
options
was introduced in ECMAScript 2022. It contains an object where one property is currently supported:
.cause
specifies which exception (if any) caused the current error.
The subsections after the next one explain the instance properties .message
and .stack
in more detail. The next section explains .cause
.
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'
.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, '');
.stack
The instance property .stack
is not an ECMAScript feature, but 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:
function h(z) {
const error = new Error();
console.log(error.stack);
}
function g(y) {
h(y + 1);
}
function f(x) {
g(x + 1);
}
f(3);
Output:
Error
at h (demos/async-js/stack_trace.mjs:2:17)
at g (demos/async-js/stack_trace.mjs:6:3)
at f (demos/async-js/stack_trace.mjs:9:3)
at demos/async-js/stack_trace.mjs:11:1
The first line of this stack trace (a trace of the call stack) shows that the Error
was created in line 2. The last line shows that everything started in line 11.
.cause
ES2022The 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');
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) {
throw new Error( // (A)
`While processing ${filePath}`,
{cause: error}
);
}
});
}
The statements inside the try
clause may throw all kinds of errors. At the locations where those errors are thrown, there is often no awareness of the file that caused them. That’s why we attach that information in line A.
If an error is shown on the console (e.g. because it was caught or logged) or if we use Node’s util.inspect()
(line A), we can see the cause and its stack trace:
import assert from 'node:assert/strict';
import * as util from 'node:util';
outerFunction();
function outerFunction() {
try {
innerFunction();
} catch (err) {
const errorWithCause = new Error(
'Outer error', {cause: err}
);
assert.deepEqual(
util.inspect(errorWithCause).split(/\r?\n/), // (A)
[
'Error: Outer error',
' at outerFunction (file:///tmp/main.mjs:10:28)',
' at file:///tmp/main.mjs:4:1',
' ... 2 lines matching cause stack trace ...',
' [cause]: TypeError: The cause',
' at innerFunction (file:///tmp/main.mjs:31:9)',
' at outerFunction (file:///tmp/main.mjs:8:5)',
' at file:///tmp/main.mjs:4:1',
'}',
]
);
}
}
function innerFunction() {
throw new TypeError('The cause');
}
Alas, we don’t see the cause if we convert an error to a string or look at its .stack
.
.cause
?error.cause
is not just for instances of Error
; any data we store in it is displayed properly:
import assert from 'node:assert/strict';
import * as util from 'node:util';
const error = new Error(
'Could not reach server', {
cause: {server: 'https://127.0.0.1'}
}
);
assert.deepEqual(
util.inspect(error).split(/\r?\n/),
[
"Error: Could not reach server",
" at file:///tmp/main.mjs:4:15",
" [cause]: { server: 'https://127.0.0.1' }",
"}",
]
);
Some people recommend using .cause
to provide data as context for an error. What are the pros and cons of doing that?
.cause
only supports arbitrary data because in JavaScript, we can throw
arbitrary data. Using it for non-thrown data means we are kind of misusing this mechanism.
.cause
for context data, we can’t chain exceptions anymore.
Error
Error
Error
has the following subclasses – quoting the ECMAScript specification:
AggregateError
ES2021 represents multiple errors at once. In the standard library, only Promise.any()
uses it.
RangeError
indicates a value that is not in the set or range of allowable values.
ReferenceError
indicates that an invalid reference value has been detected.
SyntaxError
indicates that a parsing error has occurred.
TypeError
is used to indicate an unsuccessful operation when none of the other NativeError objects are an appropriate indication of the failure cause.
URIError
indicates that one of the global URI handling functions was used in a way that is incompatible with its definition.
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);
// ···
}
}