Generator functions pause at each yield and resume when the caller requests the next value. Nothing runs until you consume the iterator — with .next(), a for...of loop, or yield*. Each value is produced on demand, which means you can walk a huge directory tree without holding all the paths in memory at once.

Directory traversal is a natural fit. The generator recurses into subdirectories with yield* and yields only the files matching a given extension filter:

import { readdir } from 'node:fs/promises';
import { extname, resolve } from 'node:path';

async function* walkDir(dir: string): AsyncGenerator<string> {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const name = resolve(dir, entry.name);
    if (entry.isDirectory()) {
      yield* walkDir(name);
    } else if (filterFile(entry.name)) {
      yield name;
    }
  }
}

const filterFile = (file: string): boolean => {
  return ['.css', '.js', '.html', '.xml', '.cjs', '.mjs', '.svg', '.txt'].some(
    (ext) => extname(file) === ext,
  );
};

Because it’s async, the event loop stays unblocked. You can also wrap it in try-catch, break early with return, or pipe results directly into processing logic.

For a deeper dive, the MDN docs on iterators and generators are worth reading.