Skip to content
JavaScript Basics: Inspect Directory Entries with opendir

JavaScript Basics: Inspect Directory Entries with opendir

October 25, 2026

A directory scan needs more than a list of names when files, subdirectories, and symbolic links require different handling. Node’s opendir returns a directory stream whose entries expose those distinctions. You can consume it with for await, stop early, or read entries manually when you need explicit lifetime control.

These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in modules, assertions, and fresh temporary directories. No packages, network access, or elevated privileges are needed. The output below is the observed output.

Prepare a scratch directory

You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep this terminal open. These commands stop the shell if setup fails; do not save any sources until both directory commands succeed. Save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

Each script creates its own fixtures. Cleanup removes only the files and empty directories that script names. An abrupt process termination can leave a temporary directory behind; inspect any leftovers before removing them.

1. Distinguish a file, a directory, and a link

Save this as classify-directory-entries.mjs:

import assert from 'node:assert/strict';
import { mkdtemp, mkdir, opendir, writeFile, symlink, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const directory = await mkdtemp(join(tmpdir(), 'directory-types-example-'));
const report = join(directory, 'report.txt');
const archive = join(directory, 'archive');
const shortcut = join(directory, 'latest');
try {
  await writeFile(report, 'ready\n', { flag: 'wx' });
  await mkdir(archive);
  await symlink('report.txt', shortcut);
  const rows = [];
  const entries = await opendir(directory);
  for await (const entry of entries) {
    const type = entry.isSymbolicLink() ? 'link'
      : entry.isDirectory() ? 'directory'
      : entry.isFile() ? 'file' : 'other';
    rows.push(`${entry.name}: ${type}`);
  }
  rows.sort();
  assert.deepEqual(rows, ['archive: directory', 'latest: link', 'report.txt: file']);
  await assert.rejects(async () => entries.read(), { code: 'ERR_DIR_CLOSED' });
  console.log(rows.join('\n'));
  console.log('directory stream: closed');
} finally {
  await rm(shortcut, { force: true });
  await rm(report, { force: true });
  await rmdir(archive);
  await rmdir(directory);
}

Run it:

node classify-directory-entries.mjs

Output:

archive: directory
latest: link
report.txt: file
directory stream: closed

The link is reported as a link even though its target is a regular file. The scan does not open the target or read report.txt. Sort the collected display rows when reproducible presentation matters; directory enumeration order is not a sort order.

The assertion after the loop verifies that the directory stream is already closed. Its async callback also catches a synchronous throw from read on a closed handle. Node’s opendir async iterator owns that cleanup when the loop exits. The Dirent methods describe the individual entry, not an opened file handle.

2. Stop after finding one matching entry

Save this as find-one-directory-entry.mjs:

import assert from 'node:assert/strict';
import { mkdtemp, opendir, writeFile, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const directory = await mkdtemp(join(tmpdir(), 'directory-find-example-'));
const wanted = join(directory, 'ready.txt');
const ignored = join(directory, 'notes.log');
try {
  await writeFile(wanted, 'ready\n', { flag: 'wx' });
  await writeFile(ignored, 'log\n', { flag: 'wx' });
  const entries = await opendir(directory);
  let found = null;
  for await (const entry of entries) {
    if (entry.isFile() && entry.name.endsWith('.txt')) {
      found = entry.name;
      break;
    }
  }
  assert.equal(found, 'ready.txt');
  await assert.rejects(async () => entries.read(), { code: 'ERR_DIR_CLOSED' });
  console.log(`matching file: ${found}`);
  console.log('break closed the directory stream');
} finally {
  await rm(wanted, { force: true });
  await rm(ignored, { force: true });
  await rmdir(directory);
}

Run it:

node find-one-directory-entry.mjs

Output:

matching file: ready.txt
break closed the directory stream

Breaking the loop still closes the stream. Do not add a second unconditional close after a for await loop; the closed-handle assertion shows why that is unnecessary. This fixture has only one matching filename, so the selected result does not depend on enumeration order. With several matches, “first found” would not mean “alphabetically first.”

The scan stores one matching name rather than an array of all names. The directory implementation can still buffer entries internally. If the name will be used for a later operation, handle that operation’s errors: the file could disappear or change after enumeration.

3. Close a manually read directory in finally

Save this as count-directory-files.mjs:

import assert from 'node:assert/strict';
import { mkdtemp, mkdir, opendir, writeFile, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

async function countJsonFiles(directory) {
  const entries = await opendir(directory);
  let count = 0;
  try {
    while (true) {
      const entry = await entries.read();
      if (entry === null) return count;
      if (entry.isFile() && entry.name.endsWith('.json')) count += 1;
    }
  } finally {
    await entries.close();
  }
}

const directory = await mkdtemp(join(tmpdir(), 'directory-count-example-'));
const first = join(directory, 'first.json');
const second = join(directory, 'second.json');
const folder = join(directory, 'archive.json');
try {
  const emptyCount = await countJsonFiles(directory);
  assert.equal(emptyCount, 0);
  await writeFile(first, '{}\n', { flag: 'wx' });
  await writeFile(second, '{}\n', { flag: 'wx' });
  await mkdir(folder);
  const fileCount = await countJsonFiles(directory);
  assert.equal(fileCount, 2);
  console.log(`empty directory: ${emptyCount}`);
  console.log(`regular .json files: ${fileCount}`);
  console.log('archive.json directory: not counted');
} finally {
  await rm(first, { force: true });
  await rm(second, { force: true });
  await rmdir(folder);
  await rmdir(directory);
}

Run it:

node count-directory-files.mjs

Output:

empty directory: 0
regular .json files: 2
archive.json directory: not counted

With manual reads, null marks the end of the directory. There is no async iterator here to close the stream, so finally performs that task even when the function returns from inside try. Combining the suffix check with isFile prevents a directory named archive.json from being counted as a data file. This does not inspect or validate JSON contents.

Practical limits

These scans visit one directory level; none walks into subdirectories. Permission failures and a missing directory reject the relevant operation. The examples let those errors propagate instead of treating an unreadable directory as empty. Entry type checks are useful for classification, but they are not a security boundary against another process changing files between a check and a later open. Use an appropriate file-access design for untrusted, concurrently modified trees. This tutorial targets Linux; the symlink fixture is not a cross-platform setup recipe.

Clean up

In the same terminal, remove only the three source files, return to your starting directory, and remove the empty scratch directory:

rm -- classify-directory-entries.mjs find-one-directory-entry.mjs count-directory-files.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on