Mobile menu button

blog

Toby Smith
AboutProjectsBlogContact
Back

Late to the party: fs.promises

14 Jul 2022

OK, I know I'm very late to the party on this, but I've only recently discovered fs.promises - and they are great!

In this article, I'm using fs.readFile as my example, but what I say applies to the whole family of "async" methods exported from "fs".

The Traditional APIs

For those who aren't aware, if you want to access the file system from within a Node.js app, you will want to use the functions provided from the "fs" import. This module exposes APIs to achieve tasks like writing & reading files, creating directories, and seeing if files exist. While there are usually synchronous versions of the exported methods, developers will often want to opt for the asynchronous versions. The traditional asynchronous APIs (the ones I've been using for the last few years) implement a design that relies on callbacks. To read in a file from disk, you have to write code that is similar to the following:

import { readFile } from "fs";
⁠
⁠readFile("/my/file.path", (err, data) => {
⁠  if (err) throw err;
⁠  console.log(data);
⁠});

The snippet above passes a callback into readFile as the second argument. Once Node.js has finished reading the file from disk, it will call the callback with either an error in the first parameter or the file contents in the second parameter. This API works just fine, but it doesn't allow you to block the execution of the calling method out of the box. Any code written after the call to readFile gets executed immediately - before Node.js has finished reading the file. In other words, the callback will be called "out of order" relative to the code around it in the file.

The Two Traditional Solutions

There are two different solutions to get around the issue of not being able to block the calling code: manually wrap readFile in a promise, or use the promisify API exported from "util".

Wrapping with a Promise

The constructor for a Promise takes in a single function; this is where you can write code that you want to await (block) from the calling method. The function can take up to two arguments, both of which are also functions. You should either call the first function on the success of your async code or the second function on failure.

await new Promise((resolve, reject) => {
⁠  // Here you can do something that you want to await// Once you're done, call resolve. On failure, call reject
⁠});

This pattern is perfect for wrapping a call to the readFile method exported from "fs".

await new Promise((resolve, reject) => {
⁠  readFile("/my/file.path", (err, data) => {
⁠    if (err) {
⁠      reject(err);
⁠      return;
⁠    }
⁠    resolve(data);
⁠  });
⁠}); 

Due to the use of the await keyword before the Promise constructor, the calling code will block until the Promise resolves. As the Promise's resolve function is only called in the readFile callback, the promise won't resolve until after the file has been read in full.

Having to wrap the call to readFile in a Promise is fine. However, it's a fair bit of extra bulk, especially if you do it in multiple places throughout a codebase. I would typically create a "file utils" module containing functions that simply wrap fs functions with Promises... Which, again, is fine.

Promisify

Another Node.js standard library import alongside "fs" is "util"; and one of the functions exported from "util" is called "promisify". Using promisify you can wrap any callback-based function to make it return a Promise that resolves to the intended result. The only conditions are that the last argument must be where you pass in the callback and that callback must have the (err, result) => void signature we explored above with fs.readFile.

Usage of promisify might look like this:

import { readFile } from "fs";
⁠import { promisify } from "util";
⁠
⁠const readFileAsync = promisify(readFile);
⁠
⁠export const myFunction = async () => {
⁠  const fileContent = await readFileAsync("/my/file.path");
⁠  return fileContent;
⁠};

Using promisify, you're able to call readFile while still blocking the calling code while the file is read from disk.

There are some tradeoffs, however; promisify can struggle with TypeScript types. I've often had it infer that an argument needs to be a Buffer when there's also an overload that can take a string. It also still results in the extra code const readFileAsync = promisify(readFile);... Which isn't much, but is at best, only fine.

The 'new' Solution

As I said at the beginning, I'm a little late to the party on this one, but there is now an alternative. As of Node.js version 10 (2018!), there's a 'new' standard library called "fs.promises". Using the functions imported from this module, you can perform all the same actions as you can from the "fs" import, except they have signatures that don't accept callback arguments and instead return promises!

import { readFile } from "fs/promises";
⁠
⁠const fileContent = await readFile("/my/file.path");

And there you go! A simple way to interact with the filesystem using await out of the box.

I wish I knew about this sooner...

Copyright Toby Smith 2024