Understanding Promises in JavaScript

A Promise is a promise from the producing code to the consuming code that I (the producing code) will return something that you (the consuming code) can work with.

The producing code is often something that takes time, so it is usually written in an asynchronous manner, allowing it to be executed in parallel with other less time-consuming tasks. The consuming code is something that needs to use the result of the producing code once it is ready.

The promise is what links them together. This is important because in practice, you cannot expect the producing code to always be successful. It is possible that the time-consuming task eventually fails and returns something that the consuming code cannot work with.

Compared to the callback style syntax we've seen in the previous lesson, promises offer a cleaner and more intuitive way of writing asynchronous programs.

The Promise constructor

A promise is created with a Promise constructor, which has the following syntax:

javascript
1const promise = new Promise((resolve, reject) => {
2  . . . // Producing code
3});

The Promise constructor accepts a function argument, which contains the producing code. The function takes two callbacks as the input, resolve() and reject().

The producing code will be executed immediately when new Promise() is created, and if it gives the right outcome, resolve() should be executed. And if something goes wrong, reject() should be executed. For example,

javascript
1const promise = new Promise((resolve, reject) => {
2  const rand = Math.random() * 100;
3
4  if (rand >= 50) {
5    resolve(rand);
6  } else {
7    reject(new Error("Random number is less than 100"));
8  }
9});

In this case, Math.random() generates a random number between 0 and 1, so rand should be a random number between 1 and 100.

If rand is greater than or equal to 50, the promise is considered successfully resolved, and resolve() will be executed. If not, the promise is not successfully resolved, and reject() will be executed.

Resolving a promise

The Promise() constructor creates an object with its state property equals to "pending", after the promise has been resolved, it will become "fulfilled".

To fulfill a promise, you need to connect the producing code with the consuming code using then().

javascript
1promise.then(
2  (result) => {
3    console.log(`Promise fulfilled: ${result}`);
4  },
5  (error) => {
6    console.error(`Promise rejected: ${error}`);
7  }
8);

The method then() accepts two arguments. If the producing code is successful, the first argument will be executed as resolve(). For example, in case of success, resolve(rand) will be executed. The value of rand will be passed to the argument result, and becomes:

javascript
1(rand) => {
2  console.log(`Promise fulfilled: ${rand}`);
3};

Which gives something like:

text
1Promise fulfilled: 86.03118001726733

In case of failure, the second argument will be executed as reject(). new Error(. . .) will be passed as error, and produce:

text
1Promise rejected: Error: Random number is less than 100
2    at /Users/. . ./index.js:149:12
3    . . .

Try the code example on your computer a few times, and it should give you both results.

The second argument is optional for the then() method. You can choose to handle only the success case, and then chain another catch() method to handle the unsuccessful case.

javascript
1promise
2  .then((result) => {
3    console.log(`Promise fulfilled: ${result}`);
4  })
5  .catch((error) => {
6    console.error(`Promise rejected: ${error}`);
7  });

This makes a slightly cleaner syntax.

Even though our previous example demonstrates how a promise works, it is not very practical. Because the producing code is usually asynchronous. It should be something that is time consuming, but our example is synchronous and rand will be created almost instantly without any delay.

So, here is a more realistic example:

javascript
1const success = true;
2
3const promise = new Promise((resolve, reject) => {
4  // Simulating a time-consuming task
5  setTimeout(() => {
6    if (success) {
7      resolve("Task finished.");
8    } else {
9      reject(new Error("Task failed."));
10    }
11  }, 1000);
12});
13
14promise
15  .then((result) => {
16    console.log(`Promise fulfilled: ${result}`);
17  })
18  .catch((error) => {
19    console.error(`Promise rejected: ${error}`);
20  });

Here, we are using setTimeout() to simulate a time-consuming task, and success indicates the status of the task. If success is true, the promise will be resolved, and if success is false, the promise will be rejected.

Why use a promise

So why use a promise at all? Remember that previously we are using setTimeout() directly, but now we have to wrap it inside a promise. Seems like we are only making things more complex here.

The whole point of using a promise is to turn an asynchronous operation that requires callback functions into a promise-based syntax that looks like:

javascript
1promise
2  .then((result) => {
3    console.log(`Promise fulfilled: ${result}`);
4  })
5  .catch((error) => {
6    console.error(`Promise rejected: ${error}`);
7  });

This process is known as promisification. For example, previously, we are reading a file like this:

javascript
1fs.readFile("file.txt", "utf8", function (err, data) {
2  console.log(data);
3});

To promisify this readFile() function, this is what we can do:

javascript
1function read(filePath) {
2  return new Promise((resolve, reject) => {
3    fs.readFile(filePath, "utf8", (err, data) => {
4      if (err) {
5        reject(err);
6      } else {
7        resolve(data);
8      }
9    });
10  });
11}

Remember that the producing code will be executed immediately when new Promise() is created. However, in this case, you might not want to do that because the producing code is often time-consuming. So instead, we are making a function read() to return new Promise(). This way, the producing code will only be executed when we call read().

And now, the old school callback syntax can be rewritten into:

javascript
1read("./file.txt").then(console.log).catch(console.log);

Promise chaining

The promise enables you to turn operations that usually require multiple callbacks into a chain of then() methods. For example, normally, if you need to read multiple files, you'll need to use readFile() like this:

javascript
1// Read first file
2fs.readFile("file1.txt", "utf8", function (err, data) {
3  console.log(data);
4});
5
6// Read second file
7fs.readFile("file2.txt", "utf8", function (err, data) {
8  console.log(data);
9});
10
11// Read third file
12fs.readFile("file3.txt", "utf8", function (err, data) {
13  console.log(data);
14});

But with the promise syntax, you could make the then() method return another promise:

javascript
1read("./file1.txt")
2  .then((data) => {
3    console.log(data);
4    return read("./file2.txt"); // Return another promise to read the second file
5  })
6  .then(. . .);

The second promise will be caught by the next then(), where you can process the second file, and then return another promise for the third file.

javascript
1read("./file1.txt")
2  .then((data) => {
3    console.log(data); // Print the first file
4    return read("./file2.txt"); // Promise the second file
5  })
6  .then((data) => {
7    console.log(data); // Print the second file
8    return read("./file3.txt"); // Promise the third file
9  })
10  .then((data) => {
11    console.log(data); // Print the third file
12  });
text
1This is file number 1
2This is file number 2
3This is file number 3

But, what if there is an error? By default, the execution of the chain will stop when an error is encountered. For example, in the first then() method, let's return a promise for a file that does not exist.

javascript
1read("./file1.txt")
2  .then((data) => {
3    console.log(data); // Print the first file
4    return read("./notexist.txt"); // Promise the second file, which does not exist
5  })
6  .then((data) => {
7    console.log(data); // Print the second file
8    return read("./file3.txt"); // Promise the third file
9  })
10  .then((data) => {
11    console.log(data); // Print the third file
12  });
text
1This is file number 1
2node:internal/process/promises:289
3            triggerUncaughtException(err, true /* fromPromise */);
4            ^
5
6[Error: ENOENT: no such file or directory, open './notexist.txt'] {
7  errno: -2,
8  code: 'ENOENT',
9  syscall: 'open',
10  path: './notexist.txt'
11}
12
13Node.js v21.6.0

JavaScript stops at the point where the error occurs, and the third file will not be promised.

To solve this problem, make sure you catch() the error at every then(). This gives JavaScript a way to deal with errors, instead of causing the entire program to crash.

javascript
1read("./file1.txt")
2  .then((data) => {
3    console.log(data); // Print the first file
4    return read("./notexist.txt"); // Promise the second file
5  })
6  .catch(console.log) // Catch the error
7  .then((data) => {
8    console.log(data); // Print the second file
9    return read("./file3.txt"); // Promise the third file
10  })
11  .catch(console.log) // Catch the error
12  .then((data) => {
13    console.log(data); // Print the third file
14  })
15  .catch(console.log); // Catch the error
text
1This is file number 1
2[Error: ENOENT: no such file or directory, open './notexist.txt'] {
3  errno: -2,
4  code: 'ENOENT',
5  syscall: 'open',
6  path: './notexist.txt'
7}
8undefined
9This is file number 3

This time, even though the second file still does not exist, JavaScript simply catches the error and logs the message to the console. The chain does not break in this case.

Rewrite compareFiles()

Going back to our previous lesson, and remember that we created a compareFiles() using two nested readFile() functions.

javascript
1function compareFiles(f1, f2) {
2  fs.readFile(f1, "utf8", (error, data1) => {
3    fs.readFile(f2, "utf8", (error, data2) => {
4      if (data1 === data2) {
5        console.log("Files are the same.");
6      } else {
7        console.log("Files are not the same.");
8      }
9    });
10  });
11}

This example only compares two file, and it is already difficult to read and understand. As we compare more and more files, this nested callbacks syntax will become a mess. So instead, let's see if we can recreate it using promises.

First of all, we need to promisify the readFile() function.

javascript
1function read(filePath) {
2  return new Promise((resolve, reject) => {
3    fs.readFile(filePath, "utf8", (err, data) => {
4      if (err) {
5        reject(err);
6      } else {
7        resolve(data);
8      }
9    });
10  });
11}

As for compareFiles(), things get complicated.

javascript
1function compareFiles(f1, f2) {
2  read(f1).then((data1) => {
3    read(f2).then((data2) => {
4      // Compare data1 and data2 here
5    });
6  });
7}

Because we'll need to compare data1 and data2, and with what we've learned so far, it seems like the only way to have access to both of them is to read f1 first to get data1, and then inside the then() method, read f2 to get data2.

This is not an elegant solution at all. We are still using the nested structure we are trying to get rid of.

Another way to do this is to use Promise.all(), which is a static method under the Promise constructor. This method allows you to handle multiple promises together. For example:

javascript
1function compareFiles(f1, f2) {
2  return Promise.all([read(f1), read(f2)])
3    .then(([data1, data2]) => {
4      if (data1 === data2) {
5        console.log("Files are the same.");
6      } else {
7        console.log("Files are not the same.");
8      }
9    })
10    .catch((error) => console.error("Error reading files:", error));
11}

read(f1) returns the promise for the first file, and read(f2) returns the promise for the second file. The two of them are put together in an array. The following then() method deals with both of the parsed file data, data1 and data2, which are also put in a array.

If you need to compare more files, simply add more promises in the array.

javascript
1function compareFiles(f1, f2, f3) {
2  return Promise.all([read(f1), read(f2), read(f3)])
3    .then(([data1, data2, data3]) => {
4      if (data1 === data2 && data2 === data3) {
5        console.log("Files are the same.");
6      } else {
7        console.log("Files are not the same.");
8      }
9    })
10    .catch((error) => console.error("Error reading files:", error));
11}
12
13compareFiles("./file1.txt", "./file2.txt", "./file3.txt");

Compared to the previous compareFiles(), this new syntax is much easier to read and maintain.

Understanding Promises in JavaScript | TheDevSpace