Utility Script Patterns
Common patterns for writing Node.js utility scripts — file I/O, globbing, colored output, interactive prompts, and spinners.
Recipe
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.
Working Example
// 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:
- Async file I/O via
node:fs/promises(neverreadFileSyncin an async script) fast-globfor fast, flexible file matching with ignore patterns@inquirer/promptsnamed-function API (replaces the legacyinquirer.prompt()object API)oraspinner with dynamictextupdates andsucceed/failstates- Proper error handling that sets
process.exitCodeinstead of callingprocess.exit()
Deep Dive
How It Works
node:fs/promisesexposes allfsfunctions as promise-returning variants. No need forutil.promisifyanymore.fast-globis the fastest widely-used glob library for Node. It returns a plainstring[]by default (orEntry[]withobjectMode: true).chalkv5 is ESM-only. If you're on CommonJS, either pin to chalk v4 or convert your project to ESM.@inquirer/promptsis the modern replacement for the monolithicinquirerpackage. Each prompt (input,confirm,select,checkbox) is imported as a named function that returns a promise.orawrites to stderr by default and detects TTY to disable animations in CI. You can force state via{ isEnabled: process.stdout.isTTY }.
Variations
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`);TypeScript Notes
fs.readFile(path, "utf8")returnsPromise<string>. Without the encoding, it returnsPromise<Buffer>. Always pass"utf8"when you want a string.fast-globreturnsPromise<string[]>when called without options. With{ objectMode: true }it returnsPromise<Entry[]>.@inquirer/promptsinfers return types from the prompt:confirm()returnsPromise<boolean>,input()returnsPromise<string>, andselect<T>()accepts a generic for the choice value.- Type the top-level script error handler as
(err: unknown)and narrow before logging.
Gotchas
Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.
-
Using
fs.readFileSyncin an async script — Blocks the event loop, defeats concurrency, and makes spinners stutter. Fix: Useimport { readFile } from "node:fs/promises"andawaitit. Sync APIs are only appropriate in startup code before any async work. -
Forgetting to
awaitanfs.promisescall —writeFile(path, data)withoutawaitreturns an unhandled promise. The script exits before the write completes, silently losing data. Fix: Alwaysawait, and enable@typescript-eslint/no-floating-promisesto 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=1as an env var in CI, or usenew Chalk(\{ level: 3 \})to force color level. -
Relative vs absolute paths in glob —
fast-globreturns paths relative tocwdby default. Passing those tofs.readFileworks only if the script is run from the same directory. Fix: Use{ absolute: true }or resolve manually withpath.resolve(process.cwd(), file). -
Emoji breaking Windows terminals — Legacy
cmd.exeand older PowerShell render emoji as garbled bytes. Fix: Detect Windows viaprocess.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 pendingfs.writeFilecalls. Fix: Setprocess.exitCode = 1and let the event loop drain naturally.
Alternatives
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.) |
FAQs
Why prefer node:fs/promises over util.promisify(fs.readFile)?
node:fs/promisesis the canonical modern API — no wrapping needed.- Every
fsfunction has a promise variant already exported. util.promisifyis only useful for older third-party callback APIs.
Why use fast-glob instead of the built-in fs.readdir recursive option?
fs.readdir(\{ recursive: true \})(Node 20+) works for simple cases but has no glob pattern support.fast-globsupports**,*, brace expansion, negation, and ignore patterns.- For anything beyond a flat recursive listing,
fast-globis simpler and faster.
What's the difference between @inquirer/prompts and legacy inquirer?
- Legacy
inquirerexposes a monolithicinquirer.prompt([\{ type, name, message \}])API. @inquirer/promptsexposes each prompt as a named function:confirm(),input(),select().- The new API is lighter, tree-shakeable, and has better TypeScript inference.
Why does chalk v5 require ESM?
- Chalk v5 dropped CommonJS support to reduce maintenance burden.
- Either migrate your project to ESM (
"type": "module"inpackage.json). - Or pin chalk to v4 which still supports CommonJS
require().
Gotcha: Why does my script exit before files finish writing?
- You forgot to
awaitanfs.writeFilecall, or you calledprocess.exit()too early. - Unhandled promises don't keep the event loop alive the way awaited ones do.
- Fix:
awaitevery async call and useprocess.exitCode = 1instead ofprocess.exit(1).
Gotcha: Why are chalk colors missing in my CI logs?
- Chalk auto-detects TTY and disables color in non-interactive environments.
- CI runners typically pipe stdout, so
isTTYis false. - Fix: Set
FORCE_COLOR=1in your CI env, or configure a higher color level explicitly.
TypeScript: How do I type the return value of fs.readFile?
- With
"utf8"encoding:Promise<string>. - Without encoding:
Promise<Buffer>. - Always pass the encoding explicitly when you want a string, or use a generic wrapper.
TypeScript: How do I type an @inquirer/prompts select prompt?
select()accepts a generic type parameter for the chosen value.- Example:
const choice = await select<"a" | "b">(\{ message, choices \}). - The return type is inferred from the generic, giving a string literal union.
How do I show dynamic progress in an ora spinner?
- Reassign
spinner.textinside your loop — ora re-renders on each tick. - Call
spinner.succeed(msg)orspinner.fail(msg)to stop with a final state. - For true progress (x of N), use
cli-progressinstead.
When should I stream a file instead of reading it into memory?
- Files larger than ~100 MB, or files that don't fit your process memory budget.
- Use
createReadStream+readline.createInterfacefor line-by-line iteration. - Streams keep memory flat regardless of file size.
How do I safely write JSON with a trailing newline?
JSON.stringify(obj, null, 2) + "\n"— the trailing newline matches POSIX conventions.- Always await
writeFileand use"utf8"encoding. - For concurrent writes to the same file, use a lock or serialize through a queue.
Why use process.exitCode instead of process.exit?
process.exit(code)terminates immediately, truncating pending stdout writes and async operations.process.exitCode = codesets the exit code but lets the event loop drain naturally.- The script exits with your code once all pending work completes — no data loss.
Related
- ESLint for Node.js Scripts — lint your scripts with type-aware rules
- Git Utilities — shell tooling for automation scripts
- TypeScript Basics — tsconfig for Node projects
- Environment Variables — loading config into scripts