Deno and the file system

Deno and the file system

Many applications interact with the host file system in some way. For this we have several functions in Deno that are provided to us.

In order to use these in our applications, we need to remember to use the runtime flags, such as --allow-read and/or --allow-write.

In the following chapter we will look at how to interact with the filesystem, work with directories or manage files. At the end of the chapter we will create an application, similar to ls, which can give us information about directories.

Interact with Directories

Checking the contents of a folder

If we want our application to know what is in a folder, Deno offers us an easy way to find out. Deno.readDir() or synchronously Deno.readDirSync(). This method, like many of the following, is similar to NodeJS.

Creating new directories

As we are used to from the terminal/console, we can create new directories with Deno. The command is Deno.mkdir().

We have to keep in mind that the system does not check if the directory already exists when the command is being executed. If it does, Deno.mkdir("/my/path") will return an error. So we need to check that the directory exists first. We can do this with Deno.stat("/my/path"). Let’s look at this in an example.

const path = "my-dir";

try {
  const stat = await Deno.stat(path);
  if (!stat.isDirectory) {
    throw new Error(`Path exists but is not a directory: ${path}`);
  }
  console.log(`Path exists and is a directory: ${path}`);
} catch (error) {
  if (error instanceof Deno.errors.NotFound) {
    console.log(`Path does not exist. We can create it: ${path}`);
    await Deno.mkdir(path);
  }
}

We can also create subfolders in one step. To do this, we pass a second parameter to the Deno.mkdir() function’s options with { recursive: true }.

const path = "my-dir/sub-dir";

try {
  const stat = await Deno.stat(path);
  if (!stat.isDirectory) {
    throw new Error(`Path exists but is not a directory: ${path}`);
  }
  console.log(`Path exists and is a directory: ${path}`);
} catch (error) {
  if (error instanceof Deno.errors.NotFound) {
    console.log(`Path does not exist. We can create it: ${path}`);
    await Deno.mkdir(path, { recursive: true });
  }
}

Walk directories

For example, if we want to build an application that displays the file system and its contents, we have the option of traversing the file system from a starting point. This method is provided by the @std/fs module from the standard library. Using an additional function from the @std/path library, we can create a kind of tree application.

import { walk } from "jsr:@std/fs/walk";
import { relative, SEPARATOR } from "jsr:@std/path";

const path = ".";

for await (const entry of walk(path, { includeDirs: true })) {
  const relativePath = relative(path, entry.path);
  const depth = relativePath.split(SEPARATOR).length - 1;
  const indent = "-->".repeat(depth);
  const prefix = entry.isDirectory ? "📁 " : "📄 ";
  console.log(`${prefix}${indent} ${entry.name}`);
}

An example output would be the following, which shows my test project:

 deno run -A walk.ts
📁  .
📁  .zed
📄 --> settings.json
📄  main.ts
📄  deno.json
📄  walk.ts
📄  deno.lock
📄  .env
📄  flags.ts
📁  my-dir
📁 --> sub-dir

📋 Further Documentation

Creating, reading and deleting files

Now we have looked at directories, interacted with them and created them. Of course, not only directories can be created, but files of all kinds. There are several methods in the Deno namespace and in the @std/fs library that we can use to do this.

The most important ones are described below.

Creating a new file

To create a new file, we can differentiate between a text file and a general file.

Creating text files

Deno offers us a simpler method for the former. We can use Deno.writeTextFile() to pass strings directly as input to the text file. We have an asynchronous promise-based method on the one hand, and a synchronous variant on the other. We have to wait for the former with an `await’, but the following code can continue to run and will not block the application.

// async
await Deno.writeTextFile("hello.txt", "Hello, Deno!");
// sync
Deno.writeTextFileSync("hello.txt", "Hello, Deno!");

First we have the filename we want to assign. Then we can pass any string to be written to the text file. If the file already exists, it will be overwritten.

Of course, we don’t always want to create a new file and overwrite existing data. To do this, we can use the method options (3rd parameter) to append our text to a file.

await Deno.writeTextFile("hello.txt", "Hello, Deno!", { append: true });

Creating arbitrary files

It is possible to use the Deno.create() method to create an empty file and optionally write bytes to it afterwards. Deno will then return a Deno.FsFile object that we can work with (read, write, truncate, etc.).

const f = await Deno.create("hello.txt");
const stats = await f.stat();
console.log(stats.isFile); // Result: true

To write bytes to this file, we need a Writer (and a TextEncoder if we want to write text). Let’s have a look at what this looks like in code.

const file = await Deno.create("hello.txt");

// check the file size
const statsBefore = await file.stat();
console.log(statsBefore.size); // Result: 0

const writer = file.writable.getWriter();
await writer.write(new TextEncoder().encode("Hello Deno!"));

// check the size now
const statsAfter = await file.stat();
console.log(statsAfter.size); // Result: 11

// close the file so we don't leak
writer.close(); // or f.close() - writer.close() also closes the file

A simple workflow is provided by Deno.writeFile(). This allows us to significantly shorten the previous code.

await Deno.writeFile(
  "hello.txt",
  new TextEncoder().encode("Hello Deno!"),
);

const stats = await Deno.stat("hello.txt");
console.log(stats.size); // Result: 11

Reading the contents of a file

When we want to read a file, we can differentiate between a text file and a general file, just like when we create a file. For text files, there is a method that returns a string directly. If we want to read the file we created earlier, we can do this as follows.

const content = await Deno.readTextFile("hello.txt");
console.log(content); // Hello Deno!

However, if we want to load an arbitrary file into memory, we can use the Deno.readFile() method. This will return a byte array that we can work with.

const bytes = await Deno.readFile("hello.txt");
console.log(new TextDecoder().decode(bytes));

We don’t have to worry about closing them with either method. Deno does that for us.

As with Deno.create(), we can open files to work with. For this we have the Deno.open() method. This will open the file and we can then work with it. Be it reading or writing. As with Deno.create() we get a Deno.FsFile object.

Removing files or directories

Deleting files and directories is simpler. Deno does not differentiate between the two types. With Deno.remove() or Deno.removeSync() we can remove both types.

// remove a file
await Deno.remove("hello.txt");

// remove an empty directory
await Deno.remove("my-dir");

// remove a non-empty directory
await Deno.remove("my-dir", { recursive: true });

When we try to delete a non-empty directory, we get an error message from Deno error: Uncaught (in promise) error: Directory not empty (os error 66): remove "my-dir". So we need to set the { recursive: true } option deliberately to remove everything in that directory.

Renaming and moving files

Sometimes we just want to move or rename files. For this we have the Deno.rename() function, which behaves similarly to the mv we may know from the terminal. The function is quickly explained. The first parameter is the current location and the second is the future location.

await Deno.rename("hello.txt", "./public/hello.txt");

It is important to note that the source location must be readable and the destination location must be writable for our application.

Temporary files

Temporary files are great for large amounts of data that you don’t want to keep in memory. If they are to be used later in the program, we can use them to store data temporarily. Or, if you want to send them somewhere else, you can simply remove them at the end of the program. By default, these files are created in the operating system’s temporary folder (such as /tmp or /var/folders). To get the current temporary directory, we can use one of the following two methods (preferably the first)

// method 1 via NodeJS compatibility layer
import { tmpdir } from "node:os";
const tempPath = tmpdir();
console.log(tempPath);

// method 2 using Deno environment variables
const tempDir = Deno.env.get("TMPDIR") ||
  Deno.env.get("TMP") ||
  Deno.env.get("TEMP");
console.log(tempDir);

For the creation of temporary files or directories we then have the functions Deno.makeTempDir() and Deno.makeTempFile().

const tempPath = await Deno.makeTempDir();
console.log(tempPath);

const tempFile = await Deno.makeTempFile();
console.log(tempFile);

Both functions return the path to the directory or file created, so we can interact with it.

The options that are optional as parameters help us to better identify the created files, as they have gibberish looking names. For example /var/folders/j_/g0c8_fyd0zq3vg76xr8xcyhc0000gn/T/fc8e227ff388a6c2. To make the files or directories more identifiable, we can use either {prefix: ""} or {suffix: ""} as an option (or both).

With the following code we get results like /var/folders/j_/g0c8_fyd0zq3vg76xr8xcyhc0000gn/T/temp_f0bba33b51e5bb67.txt.

const tempFile = await Deno.makeTempFile({ prefix: "temp_", suffix: ".txt" });
console.log(tempFile);

That way we know that it’s probably a text file we’re working with. This makes it easier to find and work with.

📋 Further Documentation

Manage files (permissions, directories, statistics)

See statistics about files and directories

We can use Deno functions to get all sorts of information about our environment. For example, we can get statistics about files and directories. The Deno.stat() function is available for this purpose.

In the following we will look at a slimmed down implementation of the terminal application ls. We want to give the user information about the current directory. The output should return the available files, with optional CLI arguments for file size and last modified date.

In the following case, we create a file called ls.ts and insert the following code.

import { parseArgs } from "jsr:@std/cli/parse-args";
import { ensureDir } from "jsr:@std/fs";

interface FileInfo {
  name: string;
  size: number;
  mtime: Date | null;
  isDirectory: boolean;
  isFile: boolean;
  isSymlink: boolean;
}

interface Options {
  all: boolean;
  long: boolean;
  human: boolean;
}

/**
 * Convert a file size in bytes to a human-readable string.
 * @param sizeBytes The size in bytes.
 * @param human Whether to use human-readable units.
 * @returns The size as a string.
 */
function convertSize(sizeBytes: number, human = false): string {
  if (!human) return sizeBytes.toString();

  const units = ["B", "K", "M", "G", "T"];
  let size = sizeBytes;
  let unitIndex = 0;

  while (size >= 1024 && unitIndex < units.length - 1) {
    size /= 1024;
    unitIndex++;
  }

  return `${size.toFixed(1)}${units[unitIndex]}`;
}

/**
 * Get file information for a directory entry.
 * @param path The path to the directory.
 * @param entry The directory entry.
 * @returns The file information.
 */
async function getFileInfo(
  path: string,
  entry: Deno.DirEntry,
): Promise<FileInfo> {
  const fullPath = `${path}/${entry.name}`;
  const stat = await Deno.stat(fullPath);

  return {
    name: entry.name,
    size: stat.size,
    mtime: stat.mtime,
    isDirectory: entry.isDirectory,
    isFile: entry.isFile,
    isSymlink: entry.isSymlink,
  };
}

/**
 * List the contents of a directory.
 * @param path The path to the directory.
 * @param options The options to use.
 */
async function listDirectory(path: string, options: Options) {
  const entries: Deno.DirEntry[] = [];

  for await (const entry of Deno.readDir(path)) {
    if (!options.all && entry.name.startsWith(".")) continue;
    entries.push(entry);
  }

  // Sort entries
  entries.sort((a, b) => a.name.localeCompare(b.name));

  if (options.long) {
    // Add . and .. if showing all
    if (options.all) {
      const currentDir = await Deno.stat(".");
      const parentDir = await Deno.stat("..");

      console.log(formatLongEntry({
        name: ".",
        size: currentDir.size,
        mtime: currentDir.mtime,
        isDirectory: true,
        isFile: false,
        isSymlink: false,
      }, options));

      console.log(formatLongEntry({
        name: "..",
        size: parentDir.size,
        mtime: parentDir.mtime,
        isDirectory: true,
        isFile: false,
        isSymlink: false,
      }, options));
    }

    // Process and display each entry
    for (const entry of entries) {
      const info = await getFileInfo(path, entry);
      console.log(formatLongEntry(info, options));
    }

    return;
  }
  // Simple listing
  for (const entry of entries) {
    console.log(entry.name);
  }

  return;
}

/**
 * Format a file information object for a long listing.
 * @param info The file information.
 * @param options The options to use.
 * @returns The formatted string.
 */
function formatLongEntry(info: FileInfo, options: Options): string {
  const mtime = info.mtime
    ? info.mtime.toLocaleDateString("en-US", {
      month: "short",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
    })
    : "???";

  const size = convertSize(info.size, options.human);

  return `${size.padStart(6)} ${mtime} ${info.name}`;
}

async function main() {
  const args = parseArgs(Deno.args, {
    boolean: ["a", "l", "h"],
    alias: {
      a: "all",
      l: "long",
      h: "human",
    },
  });

  const options: Options = {
    all: args.a || false,
    long: args.l || false,
    human: args.h || false,
  };

  const path = args._[0]?.toString() || ".";

  try {
    await ensureDir(path);
    await listDirectory(path, options);
  } catch (error) {
    console.error(`Error: ${error.message}`);
    Deno.exit(1);
  }
}

if (import.meta.main) {
  main();
}

We can run this application with deno run --allow-read ls.ts <dir> <parameter>. If we run it without specifying any directories or parameters, we will get the information from the current directory.

 deno run --allow-read ls.ts
deno.json
deno.lock
ls.ts

It gets interesting when we use one (or more) of our parameters. We have the parameters -a, -l and -h. The last one works in combination with -l. When used, we get the following output from our application.

 deno run --allow-read ls.ts -alh
160.0B Nov 17, 03:35 PM .
 96.0B Oct 16, 05:37 PM ..
118.0B Nov 17, 03:36 PM deno.json
675.0B Nov 17, 03:36 PM deno.lock
  3.7K Nov 17, 03:37 PM ls.ts

As a result, we have built our own ls application in a simple way.

📋 Further Documentation

Concluding “Deno and the file system”

In this chapter we learned about Deno’s file system operations. We covered how to interact with directories, including checking their contents, creating new ones, and traversing through them using the walk function. We then explored file operations such as creating, reading, and deleting files, as well as working with temporary files. The chapter demonstrated both synchronous and asynchronous methods, showing how to handle file permissions and statistics.

Throughout the examples, we saw how Deno provides a secure approach to file system operations by requiring explicit permissions through runtime flags like --allow-read and --allow-write. We concluded with a practical implementation of an ls-like command-line tool, which brought together many of the concepts we learned.