Error Handling
As we have already discussed, the web platform and browser clients have been built to be incredibly fault-tolerant and forgiving of mistakes. The way that JavaScript is executed also follows this mantra. In most cases, uncaught errors will not cause the entire page to fall down or the browser to explode, although they may very well cause other unintended behavior.
In order to create robust and resilient applications, we must understand the ways in which our code can fail. In this chapter, we will explore the mechanisms that JavaScript gives for us to detect and handle errors and exceptions in the execution of code.
Some languages make a distinction between an error and an exception, and
have different mechanisms or different classes of objects to distinguish between
the two. JavaScript does not make such a distinction, and both terms might be
used to refer to the same concept. In our discussion, as in many other texts, we
will usually use “exception” to refer to the concept of code execution following
an exceptional path and “error” to refer to an instance of a JavaScript
Error
object.
Syntax Errors
A syntax error occurs when the JavaScript interpreter encounters invalid source code when it first reads and parses the script, which happens before compilation and execution. A syntax error prevents the execution of its entire source – such as a single external script file or embedded script element – no matter how large the source nor where the syntax error occurs in the source. A tiny typo at the end of a large script will prevent the entire thing from running.
In this example head
of an HTML document, we can see that a syntax error in
one script source will prevent it from running, but will not affect other
scripts in the document. If you load such a page in your browser with developer
tools open, you will see that the engine throws a SyntaxError
logged in the
window’s console each time it encounters an invalid script.
<head>
<!--
assume `invalid-script.js` has a syntax error,
while `valid-script.js` is fine
-->
<script src="invalid-script.js"></script>
<!-- will not execute -->
<script src="valid-script.js"></script>
<!-- will execute -->
<!-- this script will NOT execute -->
<script>
console.log('this is an invalid script so this will not be logged');
myInvalidFunction();
function myInvalidFunction(a, b, c) {
console.log('whoops); // forgot to close the string!
}
</script>
<!-- the below script will still execute -->
<script>
console.log('this is a valid script');
myValidFunction();
function myValidFunction(a, b, c) {
console.log('yay!');
}
</script>
</head>
However, beware! If the valid scripts that do run are depending upon conditions that were supposed to have been set by previous invalid scripts, then they may encounter other classes of errors as they run.
Because it prevents the script from being correctly parsed in the first place, a syntax error is one of the most difficult from which to recover because the script itself will never actually run. However, syntax errors are some of the most easy to statically detect and prevent during development, and we will later explore some development tools that can help in this regard.
Runtime Exceptions
Scripts with valid syntax will compile and run. However, some kinds of errors may still occur during runtime if our code attempts to perform invalid operations.
Recall our previous discussion of the stack and execution contexts. One of the characteristics we saw is run-to-completion, wherein normal functions execute from their beginning to end in sequential order of its statements and according to any control structures.
JavaScript offers the throw
statement as a mechanism by which to indicate
exceptions in the normal flow of execution. During execution, any value can be
thrown as an exception.
if (someCondition) throw true;
if (someOtherCondition) throw 123;
if (anotherCondition) throw new Error('whoops!');
When a value is thrown, execution of the current function halts and control
moves back down the call stack until a catch
statement is reached. This not
only affects the flow of execution inside of the current function, but every
function down the current call stack all the way to the current base execution
context.
In the following example, the function two
will always throw an exception when
called. What will be the output on the console after the below script runs?
zero();
console.log('end of numbers');
function zero() {
console.log('zero');
one();
two();
three();
}
function one() {
console.log('one');
}
function two() {
throw 'throwTwo';
console.log('two');
}
function three() {
console.log('three');
}
show console
> "zero"
> "one"
Execution enters the function two
, but when the exception is thrown, the
execution stops and moves back down the stack into where it branched off from
within zero
. At that point in zero
there is no code to catch the exception,
so control continues moving down the stack, in this case back into the global
context. At that point in the global context (where zero
was called) there is
also no code to handle the thrown exception, so execution of the current stack
halts entirely. Depending on the JavaScript engine and environment being used,
this ultimately uncaught exception (the string 'throwTwo'
) might also be shown
in the console automatically.
Notice that the function three
is never called, nor does execution ever reach
the instruction to log 'end of numbers'
.
We can use try-catch
statements to wrap code that we believe might throw
exceptions. Consider a hypothetical function maybeThrows
which might execute
successfully or might throw an exception.
We’ll wrap the call to maybeThrows
inside of a try
block. Any code which
depends on the successful completion of maybeThrows
should also go inside of
the same try
block. This block is immediately followed by a catch
block
containing the code which should be executed in the event that an exception is
thrown.
try {
let result = maybeThrows();
console.log('It worked! The result is', result);
} catch (e) {
console.error('It threw an exception:', e);
}
console.log('this line is always reached');
Notice that the catch
block also includes a single new identifier, called the
catch binding. This will be the lexical variable name created inside the
scope of the catch
block which will be bound to the caught exception value.
Developers often give this a name like error
, err
, e
, etc.
Code that comes after the catch
block will continue execution as normal
(assuming that no code within the catch
block itself throws another
exception!). However, there may be situations in which we want to eventually
re-throw a caught error after intercepting it. We can use a finally
block in a
try-finally
or try-catch-finally
group to enclose code that we want to
always run regardless of if an exception was thrown.
function willThrow() {
try {
throw 'exception';
} finally {
console.log('finally');
}
}
try {
willThrow();
} catch (e) {
console.log('threw:', e);
}
> "finally"
> "threw: exception"
Code inside of the finally
block is always executed regardless of whether any
code in the preceding try
and catch
blocks throws or returns a value.
Additionally, if finally
code throws or returns its own value, that operation
supersedes any thrown or returned value from within the try
and catch
blocks. This can make the order of execution a bit trickier to trace, but just
remember that, in short, finally
blocks have the final say on what is
ultimately thrown or returned from a try-catch-finally
group.
function finalReturn() {
try {
throw 'exception';
} finally {
return 'success!';
}
}
try {
let value = finalReturn();
console.log('Got returned:', value);
} catch (e) {
console.log('Got thrown:', e);
}
> "Got returned: success!"
function finalThrow() {
try {
return 'success!';
} finally {
throw 'exception';
}
}
try {
let value = finalThrow();
console.log('Got returned:', value);
} catch (e) {
console.log('Got thrown:', e);
}
> "Got thrown: exception"
Error
Objects
While any value can be thrown and caught as an exception, it is generally
considered good practice to throw instances of JavaScript’s native Error
object, both for reasons of consistency in programming style and because of some
of the benefits that error objects give us “for free”. If you only throw error
objects, it can simplify the work of authors depending upon your code in that
they can use such a constraint to simplify the logic needed to handle
exceptional cases.
Error
instances can be created with new
syntax or by calling the global
object as a function. In either case, Error
takes a string message as the
first argument which is then available as the error object’s .message
property.
if (condition) throw new Error('your message here');
// or
if (condition) throw Error('your message here');
try {
let result = maybeThrowsError();
console.log('It worked! The result is', result);
} catch (err) {
console.error('We got an error about:', err.message);
}
In most execution environments, when an Error
instance is created, it also
automatically captures a stack trace at the moment when the object was created
or thrown. This trace includes a list of the function names on the stack as well
as relevant file names and line numbers of the current place of every context in
the stack. This in invaluable in debugging, as you can use this information to
quickly identify what code was involved in the execution leading up to the
exception being thrown. Though this is not a standardized behavior, most
execution environments have a similar form of stack trace functionality, and
uncaught errors are shown on the console along with special formatting and
interactive stack traces.
We can modify an earlier example to show how a browser might display the stack trace for an uncaught error.
// script.js
zero();
console.log('end of numbers');
function zero() {
console.log('zero');
one();
}
function one() {
console.log('one');
two();
}
function two() {
throw new Error('my error message');
}
> "zero"
> "one"
> Uncaught Error: my error message
at two (script.js:18)
at one (script.js:14)
at zero (script.js:9)
at script.js:3
Standard Runtime Error Types
JavaScript engines will throw more specific kinds of errors for different
runtime exceptions during execution. These errors inherit from the Error
base
class, and so can be used in much the same way.
try {
throw new TypeError('your message here');
} catch (e) {
if (e instanceof Error) {
console.log('Got message:', e.message);
}
}
> "Got message: your message here"
ReferenceError
A reference error occurs when we attempt to lexically access a variable
which has not been declared. An instance of a ReferenceError
object will be
thrown when the engine attempts to resolve a lexical identifier but fails.
let a = 42;
console.log('a =', a);
let b;
console.log('b =', b);
console.log('c =', c);
let d = 'abc';
console.log('d =', d);
> "a = 42"
> "b = undefined"
> Uncaught ReferenceError: c is not defined
TypeError
A type error occurs when we attempt to use values in a way that is
incompatible with their actual value type, such as attempting to set properties
on null
or undefined
, or attempting to call non-callable values.
// (pretend that the below exceptions are silently caught)
let foo; // = undefined
console.log(foo.a); // ➤ TypeError: Cannot read property 'a' of undefined
foo(); // ➤ TypeError: foo is not a function
let b = new foo(); // ➤ TypeError: foo is not a constructor
for (let x of foo) { // ➤ TypeError: foo is not iterable
/*...*/
}
Type errors are also thrown in some cases where invalid argument types are passed when invoking a function.
let foo = undefined;
let obj = Object.create(foo);
// ➤ TypeError: Object prototype may only be an Object or null
Error Avoidance
- using duck typing to avoid TypeErrors