Fundamentals of Web Application Development · DRAFTFreeman

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