- The Old Way of Doing Things
- The Node.js Way of Doing Things
- Error Handling and Asynchronous Functions
- Who Am I? Maintaining a Sense of Identity
- Being Polite--Learning to Give Up Control
- Synchronous Function Calls
- Summary
Error Handling and Asynchronous Functions
In the preceding chapter, I discussed error handling and events as well as the try / catch block in JavaScript. The addition of nonblocking IO and asynchronous function callbacks in this chapter, however, creates a new problem. Consider the following code:
try { setTimeout(() => { throw new Error("Uh oh!"); }, 2000); } catch (e) { console.log("I caught the error: " + e.message); }
If you run this code, you might very well expect to see the output "I caught the error: Uh oh!". But you do not. You actually see the following:
timers.js:103 if (!process.listeners('uncaughtException').length) throw e; ^ Error: Uh oh, something bad! at Object._onTimeout errors_async.js:5:15) at Timer.list.ontimeout (timers.js:101:19)
What happened? Did I not say that try / catch blocks were supposed to catch errors for you? I did, but asynchronous callbacks throw a new little wrench into this situation.
In reality, the call to setTimeout does execute within the try / catch block. If that function were to throw an error, the catch block would catch it, and you would see the message that you had hoped to see. However, the setTimeout function just adds an event to the Node event queue (instructing it to call the provided function after the specified time interval—2000 ms in this example) and then returns. The provided callback function actually operates within its own entirely new context and scope!
As a result, when you call asynchronous functions for nonblocking IO, very few of them throw errors, but instead use a separate way of telling you that something has gone wrong.
In Node, you use a number of core patterns to help you standardize how you write code and avoid errors. These patterns are not enforced syntactically by the language or runtime, but you will see them used frequently and should absolutely use them yourself.
The callback Function and Error Handling
One of the first patterns you will see is the format of the callback function you pass to most asynchronous functions. It always has at least one parameter, the success or failure status of the last operation, and very commonly a second parameter with some sort of additional results or information from the last operation (such as a file handle, database connection, rows from a query, and so on); some callbacks are given even more than two:
do_something(param1, param2, ..., paramN, function (err, results) { ... });
The err parameter is either
null, indicating the operation was a success, and (if there should be one) there will be a result.
An instance of the Error object class. You will occasionally notice some inconsistency here, with some people always adding a code field to the Error object and then using the message field to hold a description of what happened, whereas others have chosen other patterns. For all the code you write in this book, you will follow the pattern of always including a code field and using the message field to provide as much information as you can. For all the modules you write, you will use a string value for the code because strings tend to be a bit easier to read. Some libraries provide extra data in the Error object with additional information, but at least the two members should always be there.
This standard prototype methodology enables you to always write predictable code when you are working with nonblocking functions. Throughout this book, I demonstrate two common coding styles for handling errors in callbacks. Here’s the first:
fs.open('info.txt', 'r', (err, handle) => { if (err) { console.log("ERROR: " + err.code + " (" + err.message ")"); return; } // success!! continue working here });
In this style, you check for errors and return if you see one; otherwise, you continue to process the result. And now here’s the other way:
fs.open('info.txt', 'r', (err, handle) => { if (err) { console.log("ERROR: " + err.code + " (" + err.message ")"); } else { // success! continue working here } });
In this method, you use an if ... then ... else statement to handle the error.
The difference between these two may seem like splitting hairs, but the former method is a little more prone to bugs and errors for those cases when you forget to use the return statement inside the if statement, whereas the latter results in code that indents itself much more quickly and you end up with lines of code that are quite long and less readable. We’ll look at a solution to this second problem in the section titled “Managing Asynchronous Code” in Chapter 5.
A fully updated version of the file loading code with error handling is shown in Listing 3.1.
Listing 3.1 File Loading with Full Error Handling
var fs = require('fs'); fs.open('info.txt', 'r', (err, handle) => { if (err) { console.log("ERROR: " + err.code + " (" + err.message + ")"); return; } var buf = new Buffer(100000); fs.read(handle, buf, 0, 100000, null, (err, length) => { if (err) { console.log("ERROR: " + err.code + " (" + err.message + ")"); return; } console.log(buf.toString('utf8', 0, length)); fs.close(handle, () => { /* don't care */ }); }); });