Utility Script Patterns
Common patterns for writing Node.js utility scripts - file I/O, globbing, colored output, interactive prompts, and spinners.
Search across all documentation pages
Common patterns for writing Node.js utility scripts - file I/O, globbing, colored output, interactive prompts, and spinners.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
# Install the standard utility-script toolkit
npm install --save-dev \
fast-glob \
chalk \
@inquirer/prompts \
ora
# Node built-ins - no install needed
# node:fs/promises - async file I/O
# node:path - cross-platform paths
# node:url - fileURLToPath for __dirname in ESM// Minimal skeleton
import { readFile, writeFile } from "node:fs/promises";
import fg from "fast-glob";
import chalk from "chalk";
import { confirm } from "@inquirer/prompts";
import ora from "ora";When to reach for this: Any CLI utility, codemod, build step, or maintenance script where you need progress feedback and safe file operations.
// scripts/process-ts-files.ts
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import fg from "fast-glob";
import chalk from "chalk";
import { confirm } from "@inquirer/prompts";
import ora from "ora";
async function main(): Promise<void> {
// 1. Find files
const files = await fg("src/**/*.ts", {
ignore: ["**/*.d.ts", "**/node_modules/**"],
absolute: true,
});
console.log(chalk.cyan(`Found ${files.length} TypeScript files.`));
if (files.length === 0) {
console.log(chalk.yellow("Nothing to do."));
return;
}
// 2. Confirm with the user
const proceed = await confirm({
message: `Process ${files.length} files?`,
default: false,
});
if (!proceed) {
console.log(chalk.gray("Aborted."));
return;
}
// 3. Process with a spinner
const spinner = ora("Processing files...").start();
let changed = 0;
try {
for (const file of files) {
const contents = await readFile(file, "utf8");
const next = contents.replace(/\r\n/g, "\n");
if (next !== contents) {
await writeFile(file, next, "utf8");
changed += 1;
}
spinner.text = `Processing ${path.basename(file)}`;
}
spinner.succeed(chalk.green(`Normalized ${changed} file(s).`));
} catch (err) {
spinner.fail(chalk.red("Processing failed."));
throw err;
}
}
main().catch((err: unknown) => {
console.error(chalk.red("Script error:"), err);
process.exitCode = 1;
});What this demonstrates:
node:fs/promises (never readFileSync in an async script)fast-glob for fast, flexible file matching with ignore patterns@inquirer/prompts named-function API (replaces the legacy inquirer.prompt() object API)ora spinner with dynamic text updates and succeed / fail statesprocess.exitCode instead of calling process.exit()node:fs/promises exposes all fs functions as promise-returning variants. No need for util.promisify anymore.fast-glob is the fastest widely-used glob library for Node. It returns a plain string[] by default (or Entry[] with objectMode: true).chalk v5 is ESM-only. If you're on CommonJS, either pin to chalk v4 or convert your project to ESM.@inquirer/prompts is the modern replacement for the monolithic inquirer package. Each prompt (input, confirm, select, checkbox) is imported as a named function that returns a promise.ora writes to stderr by default and detects TTY to disable animations in CI. You can force state via { isEnabled: process.stdout.isTTY }.Reading and writing JSON files:
import { readFile, writeFile } from "node:fs/promises";
interface Config {
name: string;
version: string;
}
const raw = await readFile("config.json", "utf8");
const config = JSON.parse(raw) as Config;
config.version = "2.0.0";
await writeFile("config.json", JSON.stringify(config, null, 2) + "\n");Stream-based processing for large files:
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const rl = createInterface({
input: createReadStream("huge.log"),
crlfDelay: Infinity,
});
for await (const line of rl) {
// process one line at a time - memory stays flat
}Recursive directory walking without a glob library:
import { readdir } from "node:fs/promises";
// Node 20+ supports recursive: true
const entries = await readdir("src", { recursive: true, withFileTypes: true });
const files = entries.filter((e) => e.isFile()).map((e) => e.name);Progress bars with cli-progress:
import cliProgress from "cli-progress";
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
bar.start(files.length, 0);
for (const file of files) {
await process(file);
bar.increment();
}
bar.stop();Detecting TTY to silence decoration in CI:
const isInteractive = process.stdout.isTTY && !process.env.CI;
const spinner = ora({ text: "Working...", isEnabled: isInteractive });Template strings with chalk:
console.log(`${chalk.bold.blue("info")} Found ${chalk.yellow(files.length)} files`);fs.readFile(path, "utf8") returns Promise<string>. Without the encoding, it returns Promise<Buffer>. Always pass "utf8" when you want a string.fast-glob returns Promise<string[]> when called without options. With { objectMode: true } it returns Promise<Entry[]>.@inquirer/prompts infers return types from the prompt: confirm() returns Promise<boolean>, input() returns Promise<string>, and select<T>() accepts a generic for the choice value.(err: unknown) and narrow before logging.Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.
Using fs.readFileSync in an async script - Blocks the event loop, defeats concurrency, and makes spinners stutter. Fix: Use import { readFile } from "node:fs/promises" and await it. Sync APIs are only appropriate in startup code before any async work.
Forgetting to await an fs.promises call - writeFile(path, data) without await returns an unhandled promise. The script exits before the write completes, silently losing data. Fix: Always await, and enable @typescript-eslint/no-floating-promises to catch this at lint time.
Chalk colors not appearing in CI - Chalk auto-detects TTY and disables colors in non-interactive environments. Logs in GitHub Actions or CircleCI appear uncolored by design. Fix: Set FORCE_COLOR=1 as an env var in CI, or use new Chalk(\{ level: 3 \}) to force color level.
Relative vs absolute paths in glob - fast-glob returns paths relative to cwd by default. Passing those to fs.readFile works only if the script is run from the same directory. Fix: Use { absolute: true } or resolve manually with path.resolve(process.cwd(), file).
Emoji breaking Windows terminals - Legacy cmd.exe and older PowerShell render emoji as garbled bytes. Fix: Detect Windows via process.platform === "win32" and fall back to ASCII, or require Windows Terminal (UTF-8 by default).
Calling process.exit() before writes flush - process.exit(1) terminates immediately, truncating stdout and pending fs.writeFile calls. Fix: Set process.exitCode = 1 and let the event loop drain naturally.
Other ways to solve the same problem - and when each is the better choice.
| Alternative | Use When | Don't Use When |
|---|---|---|
| execa | You need to spawn external commands with good DX | You only need in-process file I/O |
| zx | You want shell-script ergonomics with JS templates | You need strict typing or prefer explicit APIs |
Bun.file / Bun shell | You're running on Bun, not Node | You target Node.js in production |
node:readline | You need interactive single-line input with no deps | You want rich prompts (select, checkbox, etc.) |
node:fs/promises is the canonical modern API - no wrapping needed.fs function has a promise variant already exported.util.promisify is only useful for older third-party callback APIs.fs.readdir(\{ recursive: true \}) (Node 20+) works for simple cases but has no glob pattern support.fast-glob supports **, *, brace expansion, negation, and ignore patterns.fast-glob is simpler and faster.inquirer exposes a monolithic inquirer.prompt([\{ type, name, message \}]) API.@inquirer/prompts exposes each prompt as a named function: confirm(), input(), select()."type": "module" in package.json).require().await an fs.writeFile call, or you called process.exit() too early.await every async call and use process.exitCode = 1 instead of process.exit(1).isTTY is false.FORCE_COLOR=1 in your CI env, or configure a higher color level explicitly."utf8" encoding: Promise<string>.Promise<Buffer>.select() accepts a generic type parameter for the chosen value.const choice = await select<"a" | "b">(\{ message, choices \}).spinner.text inside your loop - ora re-renders on each tick.spinner.succeed(msg) or spinner.fail(msg) to stop with a final state.cli-progress instead.createReadStream + readline.createInterface for line-by-line iteration.JSON.stringify(obj, null, 2) + "\n" - the trailing newline matches POSIX conventions.writeFile and use "utf8" encoding.process.exit(code) terminates immediately, truncating pending stdout writes and async operations.process.exitCode = code sets the exit code but lets the event loop drain naturally.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥