Reading, writing, walking and watching files from TypeScript: Node's node:fs/promises and node:path,
Bun's file API, and the browser's File/Blob. For data too big to hold in memory, see
Streaming data.
concatenates with the separator and normalizes; keeps relative paths relative
resolve(...parts)
right to left until an absolute path is formed, else prepends process.cwd()
relative(from, to)
path from from to to; "" if they are the same
normalize(p)
collapses .., . and doubled separators
isAbsolute(p)
true for /x (POSIX) or C:\x (Windows)
sep, delimiter
/ and : on POSIX, \ and ; on Windows
path.posix, path.win32
force one platform's rules, e.g. for URLs or archive entries
Module directory vs working directory
import { readFile } from "node:fs/promises";import path from "node:path";import { fileURLToPath, pathToFileURL } from "node:url";// ESM, Node 20.11+ and Bunconst here = import.meta.dirname; // folder of this fileconst self = import.meta.filename; // this file// older Node: derive it from the module URLconst dir = path.dirname(fileURLToPath(import.meta.url));// fs accepts file: URLs directlyconst cfgUrl = new URL("./config.json", import.meta.url);const cfg = await readFile(cfgUrl, "utf8");// "file:///tmp/a%20b.txt"pathToFileURL("/tmp/a b.txt").href;
Value
Points at
Changes when
process.cwd()
where the process was started
the user runs from another folder, or process.chdir()
import.meta.dirname
folder of the current module
never; use it for files shipped next to code
__dirname
same, in CommonJS only
not defined in ES modules
new URL("./x", import.meta.url)
file relative to the module
never; also works in bundlers and browsers
Which base a path resolves against
my-cli/ # cwd for: cd my-cli && bun src/main.ts├── src/│ ├── main.ts # import.meta.dirname is my-cli/src│ └── config.json # URL relative to import.meta.url├── data/│ └── input.csv # readFile("data/input.csv") via cwd└── package.json
default for strings; invalid bytes decode to U+FFFD
utf16le
two or four bytes per character; Windows APIs
latin1
one byte per character, 0 to 255
base64 / base64url
binary as text; base64url uses -_ and no padding
hex
two characters per byte
ascii
7-bit; avoid, use latin1 or utf8
Buffer vs Uint8Array
Buffer
Uint8Array
Where
Node and Bun
everywhere
Relationship
subclass of Uint8Array
the base type
.slice()
shares memory (deprecated alias of subarray)
copies
.subarray()
shares memory
shares memory
Encodings
toString("base64"), Buffer.from(s, "hex")
TextEncoder/TextDecoder, toBase64() where supported
Accept Uint8Array in library signatures: every Buffer is one, and the code then runs in browsers too.
TextEncoder and TextDecoder
// Uint8Arrayconst bytes = new TextEncoder().encode("héllo");const text = new TextDecoder().decode(bytes); // "héllo"// throw on invalid UTF-8 instead of inserting U+FFFDconst strict = new TextDecoder("utf-8", { fatal: true });// chunked input: keep split multi-byte chars across callsconst dec = new TextDecoder();const a = dec.decode(bytes.subarray(0, 2), { stream: true });const b = dec.decode(bytes.subarray(2)); // flushes// a === "h" (half of é held back), b === "éllo"
Streaming big files
import { createReadStream, createWriteStream,} from "node:fs";import { pipeline } from "node:stream/promises";import { createGzip } from "node:zlib";// constant memory, backpressure handled, errors propagateawait pipeline( createReadStream("big.log"), createGzip(), createWriteStream("big.log.gz"),);
createReadStream option
Default
Notes
highWaterMark
64 KiB
chunk size for file streams
start, end
whole file
byte range, end inclusive
encoding
none
set "utf8" to get strings instead of Buffers
signal
none
AbortSignal destroys the stream
Line by line
import { createReadStream } from "node:fs";import { open } from "node:fs/promises";import { createInterface } from "node:readline";const rl = createInterface({ input: createReadStream("big.csv"), crlfDelay: Infinity, // treat \r\n as one break});for await (const line of rl) { if (line.startsWith("#")) continue;}// Node 18.11+: straight from a FileHandleconst file = await open("big.log");for await (const line of file.readLines()) { if (line.includes("ERROR")) break;}
await using needs TypeScript 5.2+ and Node 20.4+ (Bun supports it). Without it, call
await fh.close() in a finally.
Writing many chunks with backpressure
import { createWriteStream } from "node:fs";import { once } from "node:events";const out = createWriteStream("rows.txt");for (let i = 0; i < 1_000_000; i++) { if (!out.write(`${i}\n`)) await once(out, "drain");}out.end();await once(out, "finish");
Watching
import { watch } from "node:fs/promises";const ac = new AbortController();try { const events = watch("src", { recursive: true, signal: ac.signal, }); for await (const { eventType, filename } of events) { // eventType: "rename" | "change"; filename may be null if (filename?.endsWith(".ts")) rebuild(filename); }} catch (err) { if ((err as Error).name !== "AbortError") throw err;}// elsewhere: ac.abort() stops the loopdeclare function rebuild(file: string): void;
Caveat
Detail
Duplicate events
one save often fires two or more; debounce
"rename"
means created, deleted or moved; stat to find out which
filename
relative to the watched dir, sometimes null
recursive
macOS, Windows, and Linux (Node 20+)
Network and virtual filesystems
may never emit; fall back to polling with fs.watchFile
Restart on change
node --watch app.ts or bun --watch instead of hand-rolled watchers
For production-grade watching (atomic saves, globbing, polling fallback) use chokidar.
showOpenFilePicker(), showSaveFilePicker() and showDirectoryPicker() read and write real files on disk
but are Chromium-only (not Firefox or Safari) and missing from TypeScript's DOM lib; feature-detect with
"showOpenFilePicker" in window and add @types/wicg-file-system-access.
The origin private file system (OPFS) is Baseline since 2023: a sandboxed per-origin disk the user never sees.
In a worker, handle.createSyncAccessHandle() gives fast synchronous reads and writes (all engines).
JSON & CSV
import { readFile, writeFile } from "node:fs/promises";import { z } from "zod";const Config = z.object({ port: z.number().int().positive(), host: z.string().default("localhost"),});type Config = z.infer<typeof Config>;async function readJson<S extends z.ZodType>( file: string, schema: S,): Promise<z.infer<S>> { const raw: unknown = JSON.parse( await readFile(file, "utf8"), ); return schema.parse(raw); // throws ZodError with a path}async function writeJson(file: string, data: unknown) { await writeFile( file, `${JSON.stringify(data, null, 2)}\n`, );}const config: Config = await readJson("config.json", Config);
Approach
When
JSON.parse + Zod
anything read at runtime; the file is external input
import cfg from "./c.json" with { type: "json" }
static data bundled with code; needs resolveJsonModule
NDJSON, one object per line
logs, exports, big data; parse line by line, see Streaming data
JSON.parse returns any: annotate the result as unknown so the compiler forces validation.
CSV caveats
// naive: fine only for data you produced, with no commasconst rows = "a,b\n1,2" .split(/\r?\n/) .map((l) => l.split(","));// writing: quote fields that need it, double inner quotesconst cell = (v: string) => /[",\r\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v;const line = (fields: string[]) => fields.map(cell).join(",");
Trap
Why naive splitting fails
Quoted commas
"Smith, J",42 is two fields, not three
Escaped quotes
"say ""hi""" means say "hi"
Newlines in quotes
one record spans several lines
BOM and \r\n
Excel exports start with U+FEFF and use CRLF
Locale delimiters
some locales use ;
Formula injection
cells starting with =, +, -, @ run in spreadsheets
Parse real-world CSV with a library (csv-parse, papaparse) and validate each row with Zod.
Errors
import { readFile } from "node:fs/promises";function isErrnoException( err: unknown,): err is NodeJS.ErrnoException { return err instanceof Error && "code" in err;}async function readIfExists(file: string) { try { return await readFile(file, "utf8"); } catch (err) { if (isErrnoException(err) && err.code === "ENOENT") { return null; } throw err; // anything else is a real failure }}
NodeJS.ErrnoException has code, errno, syscall and path, all optional.
matches /uploads-evil; compare with root + path.sep or use relative
Absolute input
resolve discards root; the check above catches it
Symlinks inside root
compare await realpath(full) too
User-chosen filenames
generate names (randomUUID()), store the original separately
Atomic writes
import { randomUUID } from "node:crypto";import { rename, rm, writeFile } from "node:fs/promises";async function writeAtomic(file: string, data: string) { const tmp = `${file}.${randomUUID()}.tmp`; // same dir try { await writeFile(tmp, data, { flush: true }); await rename(tmp, file); // readers see old or new } catch (err) { await rm(tmp, { force: true }); throw err; }}
Writing in place can leave a half-written file after a crash. The temp file must sit on the same filesystem
for rename to be atomic; flush: true (Node 20.10+) fsyncs before closing.
mode is filtered by the process umask and only applies when the file is created; use chmod to change an
existing file. Windows honors only the write bit. Node 24.4+ adds mkdtempDisposable() for await using.