9 minutes read

Async/await functions have become very popular in JS recently, that's why the Node.js team implemented a promises-based API that provides the same methods as synchronous and callback APIs. However, promises have many advantages over callbacks. They are more readable: in fact, you can make them look synchronous (in one line) with the await keyword. This makes writing and maintaining code easier compared to callbacks. Also, we can wait for multiple promises using the Promise.all() method. Promises are part of the new standard of ES6 and are widely used by programmers.

Let's dive deep and actually see how and when we can use promises in Node.js.

Why use promises?

Basically, synchronous functions, callbacks, and promises are equipped with the same methods, though it's necessary to understand their differences. Synchronous API tends to be the easiest one to start with, but it blocks the program flow. Callbacks allow us to interact with the file system in an asynchronous way, but if we have a lot of callbacks chained one inside another, we may run into callback hell, which reduces the readability and effectiveness of our code. Here's a quick reminder of the synchronous and callback functions:

const fs = require('node:fs');

// create directory synchronously (blocks the program execution)
fs.mkdirSync('sync-example');

// callback API example
fs.readFile('file.txt', {encoding: 'utf-8'}, (err, data) => {
    if(err) {
        console.log(err);
    }
    console.log(data);
});

As a result of the first function, we should see a new directory created. The second example reads a file.txt document and outputs the data (the content of the file) to the console.

If synchronous methods should be avoided and callbacks are difficult to maintain, our best choice would be to use promises. You may utilize async/await or .then syntax, both are correct and interchangeable. Compare these two examples below.

  1. Async/await example:

const { readFile } = require('node:fs/promises');

// async/await function
const read = async() => {
 try {
    const content = await readFile('file.txt', {encoding: 'utf-8'});
    console.log(content);
 } catch (error) {
    console.log(error);
 }
};

read();

2. Then/catch example:

const { readFile } = require('node:fs/promises');

// then/catch syntax
readFile('file.txt', {encoding: 'utf-8'})
.then(data => console.log(data))
.catch(err => console.log(err));

The first thing that draws our attention in both examples is the require function. It is now importing fs/promises instead of just fs. The second difference is the try/catch block inside the async function. Basically, it is trying to execute the code inside the try function, and, if any error occurs, it catches it and lets us know about it, so we can decide how to handle it. In our case, we don't have any errors, so only the try block will be executed. Similarly, in the second example, if everything is OK, .then will fire off. Otherwise, an error will be caught and printed to the console.

In this case, I had a simple string inside file.txt, and I got it printed to my console. I ran both examples and got the same result:

Running node index.js command in the console with the result – I am the original file.

Working with files

We've just seen how to read a file using promises. Let's top it off with some more examples of writing and copying files. In this example, let's stick to the async/await syntax, but you may choose whichever method you like. So, we have the file.txt, now I want to create a copy of it and write additional content to that copy. For that, first, import the copyFile and appendFile functions, then let's do some magic!

The append function accepts the filename and data as arguments. Inside the function, we fire off the fs.appendFile method. We put await before it, since we are awaiting the result of an asynchronous action. The copy function takes the filename we want to copy and a copy name. We are once again waiting for the function to complete the copying process and then writing the data to the newly created file:

const { copyFile, appendFile } = require('node:fs/promises');

const content = 'Hi Node.js!';

const append = async(filename, data) => {
    try {
       await appendFile(filename, data);
    } catch (error) {
        console.log(error);
    }
};

const copy = async(filename, copy) => {
    try {
        await copyFile(filename, copy);
        append(copy, content);
    } catch (error) {
        console.log(error);
    }
};

copy('file.txt', 'file-copy.txt');

After running this code, we'll have the file-copy.txt file created, which will have the content from the original file plus a new string like this:

Opened code editor that has three sections, the first one is file explorer with 4 files – file-copy.txt, file.txt, index.js, package.json and the middle section is content of file.txt and the third section is content of file-copy.txt

Apart from reading, copying, and writing, you can also truncate (fs.truncate()) and delete (fs.unlink()) files. Feel free to refer to Node.js Docs to learn about these and other methods.

Working with directories

It's time to learn how to create and delete directories. Before creating one, it's good to check if it already exists, so we don't occupy a lot of memory by doing unnecessary work. For that, we can use the access method: if a directory exists, a promise will be resolved with no value. Otherwise, we'll be thrown an error. This means that we need to put our directory-creating logic inside the catch block. Here is a visual example:

const { access, mkdir } = require('node:fs/promises');

const makeDir = async(dir) => {
    try {
        await mkdir(dir);
    } catch (error) {
        console.log(error);
    }
};

const checkAccess = async(dir) => {
    try {
        await access(dir);
        console.log('can access, directory exists');
  } catch {
        makeDir(dir);
  }
};

checkAccess('my-files');

After running this code, the my-files directory was created. Try it out on your local machine too, practice makes perfect!

Deleting a directory is pretty straightforward. All it takes is to provide the path that we want to delete and wrap everything up in the try/catch block. In case you have nested paths, use {recursive: true} as the second argument:

const { rm } = require('node:fs/promises');

const deleteDir = async(dir, option) => {
    try {
      await rm(dir, {recursive: option}); 
    } catch (error) {
        console.log(error);
    }
};

// deletes a single directory 
deleteDir('my-files', false);

// deletes a nested directory ('projects/auth/tests)
deleteDir('projects', true);

Conclusion

Fs module comes in three types: synchronous API, callbacks, and promises API. The latter is the evolution of the callbacks that enhances the asynchronous development experience. Promises enable us to perform CRUD operations on files as well as work with directories. The syntax may be implemented in two ways, either through async/await or then/catch.

8 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo