sed/grep/awk chore — into a typed Bun script that also composes as a pipe like a real Unix filter.
Reading and writing files is where scripts actually earn their keep. Bun gives you two functions — Bun.file and Bun.write — plus the three standard streams as first-class objects.
Bun.file(path)Bun.file(path) hands you a BunFile — a lazy, Blob-compatible handle to a file on disk. Creating it reads nothing; you pull the contents out when you want them. (Bun docs: file I/O)
const f = Bun.file("log.txt");
f.size; // number of bytes
await f.exists(); // false if the file isn't there
await f.text(); // whole file as a string
await f.json(); // parse JSON — like `jq`
await f.bytes(); // Uint8Array
f.stream(); // ReadableStream, for huge files
Bun.write(dest, data)Bun.write is a multi-tool: it picks the fastest system call for the combination you give it (copy_file_range, sendfile…). The destination can be a path string, a BunFile, or Bun.stdout; the data can be a string, Blob/BunFile, an HTTP Response, or a byte array. It resolves to the number of bytes written. (Bun docs: file I/O)
await Bun.write("out.txt", "transformed text"); // string → file
await Bun.write("copy.txt", Bun.file("orig.txt")); // file → file (fast copy)
await Bun.write(Bun.stdout, Bun.file("page.html")); // file → terminal
await Bun.write("index.html", await fetch(url)); // Response → file
Bun.stdin, Bun.stdout, Bun.stderrBun exposes standard input, output, and error as BunFiles. This is what makes your script a composable filter, just like grep or sed:
const text = await Bun.stdin.text(); // read all of stdin as a string
const out = text
.split("\n") // split, transform, rejoin
.filter(Boolean)
.map((l) => l.trim().toUpperCase())
.join("\n");
await Bun.write(Bun.stdout, out + "\n"); // write to stdout
Bun.write(Bun.stdout, Bun.stdin) throws "Non-regular files aren't supported yet" when stdin is a pipe. Read stdin into memory first (await Bun.stdin.text()), then write — that path is solid.
node:fsBun's Bun.file/Bun.write handle file contents. For listing or creating directories, use Bun's fast node:fs implementation:
import { readdir, mkdir } from "node:fs/promises";
const files = await readdir(".");
await mkdir("backup", { recursive: true });
| bash | Bun |
|---|---|
cat file | await Bun.write(Bun.stdout, Bun.file("file")) |
sed 's/x/y/' on a file | read .text(), .replace(), Bun.write(...) |
grep -v '^$' | .split("\n").filter(Boolean) |
jq '.field' | await Bun.file("x.json").json() |
> out.txt redirect | await Bun.write("out.txt", data) |
tr '[a-z]' '[A-Z]' | .toUpperCase() |
Bun.file("x") is lazy — nothing is read until you call .text(), .json(), .bytes(), or .stream().await Bun.write(Bun.stdout, Bun.file("f")) — destination first, data second. It's the Bun twin of cat f.await Bun.stdin.text() returns the whole piped-in input as a string. Then split, transform, and Bun.write(Bun.stdout, ...).Build filter.ts: read a file given as an argument, or stdin if none; strip blank lines; trim and uppercase each remaining line; write the result to a file if a second argument is given, else to stdout. The reading version together — start with stdin only, add the file argument after it works.
const text = await Bun.stdin.text(), transform, await Bun.write(Bun.stdout, ...).Bun.argv.slice(2)[0] as an optional input path: await Bun.file(path).text().await Bun.write(outPath, out) and print nothing else.Sample input to test against:
line one
line three
last line
Check your work (immediate feedback):
echo "line one\n\nline three\n\nlast line" | ./filter.ts
# must print exactly: (no blank lines, all caps)
LINE ONE
LINE THREE
LAST LINE
./filter.ts sample.txt out.txt && cat out.txt # same three lines, written to file, exit 0
./filter.ts missing.txt | head -1 # exit 1, Bun reports ENOENT on stderr
If the third check failed loudly with a nonzero exit, that's correct Unix-filter behavior — your script is ready to chain: ./filter.ts < raw.csv | sort | head. That's the same pipeline algebra you know, with types in the middle.
Primary source for this lesson: Bun Docs: File I/O. The cat.ts example at the bottom is the whole lesson distilled into three lines — read it, and notice how Bun.write composes a whole copy with one call.
Next up: Lesson 3 — running other programs and doing something with their exit codes.