Read, Transform, Write — File I/O

Lesson 0002 · Glossary · ← Lesson 1 · Lesson 3 →

The win, in one line: by the end of this lesson you'll have turned "read a file, tweak it, save the result" — a daily 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.

1. Reading: 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

2. Writing: 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

3. The three streams: Bun.stdin, Bun.stdout, Bun.stderr

Bun 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
Gotcha (verified on 1.4.1): 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.

Directories still mean node:fs

Bun'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 });

The bash → Bun map (this layer)

bashBun
cat fileawait Bun.write(Bun.stdout, Bun.file("file"))
sed 's/x/y/' on a fileread .text(), .replace(), Bun.write(...)
grep -v '^$'.split("\n").filter(Boolean)
jq '.field'await Bun.file("x.json").json()
> out.txt redirectawait Bun.write("out.txt", data)
tr '[a-z]' '[A-Z]'.toUpperCase()

Practice

Recall check (from memory, no peeking)

1. When is the file's content actually read?

Bun.file("x") is lazy — nothing is read until you call .text(), .json(), .bytes(), or .stream().

2. Which writes a file's content to the terminal?

await Bun.write(Bun.stdout, Bun.file("f")) — destination first, data second. It's the Bun twin of cat f.

3. Which pattern reads all of stdin?

await Bun.stdin.text() returns the whole piped-in input as a string. Then split, transform, and Bun.write(Bun.stdout, ...).

Hands-on: your first Unix filter

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.

  1. Stdin version only: const text = await Bun.stdin.text(), transform, await Bun.write(Bun.stdout, ...).
  2. Add Bun.argv.slice(2)[0] as an optional input path: await Bun.file(path).text().
  3. Add the optional output path: if present, 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.

Read this next

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.