Thursday, May 20, 2021

Callback, Promise and Async/Await by Example in JavaScript

This post is going to show,  by way of code examples, how to take a callback based API, modify it to use Promises and then use the Async/Await syntax. This post won't go into a detailed explanation of callbacks, promises or the Async/Await syntax. For such a detailed explanation of these concepts please check Asynchronous JavaScript, which is a section of MDN Web Docs, that explains asynchronicity and how callbacks, promises, and the Async/Await syntax helps with working with asynchronous JavaScript.

This post is meant for the developer who has somewhat of an understanding of asynchronicity in JavaScript, but requires a straight to the point code example to serve as a quick syntax reference for how to take a callback based API, update it to use promises and finally use the Async/Await with it.

For demonstration purposes, we are going to use the fs.readFile, which is a callback based API from reading files. We will have a file test.txt that would contain some text, we will then have a file script.js that would open the file, read the contents, and print it to the terminal.

The code will first be implemented using callbacks, it would then be updated to use Promises, and finally, instead of using Promise directly, it will be updated to use Async/Await.

Let's get started.


Using Callbacks

We first create a directory where we are going to work from, create also the file that will contain our code, and the two files that we would be reading from.

We first create the two files with contents.

$ mkdir ~/code
$ touch ~/code/script.js
$ echo "Beam me up, Scotty" > ~/code/test.txt
$ cd ~/code/

Next in the script.js file, we have the following code:

const fs = require("fs")

function readFileCallBack() {

fs.readFile("./test.txt", 'utf8',  (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  
  console.log(data.trim() + " [callback]")
 })

}

readFileCallBack()

Executing the script by running node script.js should get "Beam me up, Scotty" printed to the terminal:

$ node script.js
Beam me up, Scotty [callback]

With the callback style, the result of the asynchronous operation is handled by a function passed in to the function performing the asynchronous operation.


Using Promises

Update script.js and add a version of readFileCallback that uses promises. It looks like this:

function readFilePromise() {
  return new Promise((resolve, reject) => {
     fs.readFile("./test.txt", 'utf8',  (err, data) => {
     if (err) {
       reject(err)
       return
     }

      resolve(data.trim())
    })
  });
}


readFilePromise()
 .then(data => console.log(data  + " [promise]"))
 .catch(err => console.log(err))

Execute the script by running node script.js:

$ node script.js
Beam me up, Scotty [callback]
Beam me up, Scotty [promise]

With promises, the result of the asynchronous operation is handled by a function passed to the then function exposed by the promise object.


Using Async/Await

Update script.js and add a third version that uses the Async/Await syntax. Since Async/Await is a syntax that makes using promises easier, the Async/Await implementation would make use of the readFilePromise() function. It looks like this:

async function readFileAsync() {
  try {
    const data = await readFilePromise()
    console.log(data.trim() + " [async-await]")
  } catch (err) {
    console.log(err)
  }
}

readFileAsync()

Executing the script by running node script.js will print something similar to this, to the terminal:

Beam me up, Scotty [callback]
Beam me up, Scotty [promise]
Beam me up, Scotty [async-await]

With async/await, the result of the asynchronous operation is handled as if it was a synchronous operation. The await is responsible for this, while the function in which it is used has to be preceeded with async keyword.

The complete file with the 3 implementations is presented below:

const fs = require("fs")

// callback
function readFileCallBack() {

fs.readFile("./test.txt", 'utf8',  (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  console.log(data.trim() + " [callback]")
  
 })

}

readFileCallBack()

// promise
function readFilePromise() {
  return new Promise((resolve, reject) => {
     fs.readFile("./test.txt", 'utf8',  (err, data) => {
     if (err) {
       reject(err)
       return
     }

      resolve(data.trim())
    })
  });
}


readFilePromise()
 .then(data => console.log(data  + " [promise]"))
 .catch(err => console.log(err))


// async/await
async function readFileAsync() {
  try {
    const data = await readFilePromise()
    console.log(data.trim() + " [async-await]")
  } catch (err) {
    console.log(err)
  }
}

readFileAsync()

Error Handling

To illustrate that the error handling in the 3 implementation work as expected, rename the test.txt file and rerun the script:


$ mv test.txt test.txt.backup
$ node script.js
[Error: ENOENT: no such file or directory, open './test.txt'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: './test.txt'
}
[Error: ENOENT: no such file or directory, open './test.txt'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: './test.txt'
}
[Error: ENOENT: no such file or directory, open './test.txt'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: './test.txt'
}

Showing that the error handling code, which is to just print the error to the console, works as expected in the 3 implementations.



I am writing a book: TypeScript Beyond The Basics. Sign up here to be notified when it is ready.

No comments: