mkdir(dir, { recursive: true }) is the directory call you will write most often: it creates every missing parent and, unlike bare mkdir, succeeds silently when the directory already exists, so it is safe to run on every boot. Reading a directory has three shapes, and the right one saves a lot of stat calls.
import { mkdir, readdir, opendir } from 'node:fs/promises';
import path from 'node:path';
await mkdir('build/css/vendor', { recursive: true }); // no error if it exists
console.log('names :', (await readdir('src')).join(', '));
for (const e of await readdir('.', { withFileTypes: true })) // Dirent objects
if (e.isDirectory()) process.stdout.write(e.name + '/ ');
console.log();
console.log('recurse :', (await readdir('src', { recursive: true })).join(' | '));
for await (const e of await opendir('public')) // streams entries, closes itself
console.log('opendir :', path.join(e.parentPath, e.name), e.isFile() ? 'file' : 'dir');names : app.js, models, routes build/ logs/ public/ src/ recurse : app.js | models | routes | routes\posts.js | routes\users.js | models\User.js opendir : public\img dir opendir : public\index.html file
Plain readdir returns strings and says nothing about types, so you end up calling stat on every name. withFileTypes: true returns fs.Dirent objects, and on most file systems the type arrives free with the directory read: isFile(), isDirectory() and isSymbolicLink() answer without another syscall. Each Dirent also carries parentPath, the directory it was read from, which you join with name to rebuild a usable path. Its predecessor dirent.path was removed (deprecation DEP0178) for behaving inconsistently across release lines.
The recursive: true option walks the whole tree in one call and returns paths relative to the starting directory — note the backslashes above, one more reason to normalize before storing them. It does not follow symbolic links, and it buffers every result in memory, so it is the wrong tool for a node_modules tree.
opendir is the alternative. It returns an fs.Dir you iterate with for await, reading entries in batches, so memory stays flat however many files there are. The loop closes the directory handle when it ends, including when you break out early.