I/O #
Input/Output is the foundation of almost every real-world program — reading configuration from files, writing logs, processing large CSVs, or receiving input from users in the terminal. In Node.js, all I/O operations are asynchronous by nature, which means your program doesn’t have to stop and wait for the disk or network to finish. TypeScript layers all of this with a type system that ensures you handle return values and errors correctly. This article covers fs/promises as the main API for file operations, streams for data too large to load into memory at once, and safe I/O handling patterns for production.
The fs and Path Modules #
Node.js provides two versions of the file system API — fs based on callbacks and fs/promises based on Promises. In modern TypeScript, always use fs/promises to take full advantage of async/await.
import { promises as fs } from "fs";
import path from "path";
// or with ES module syntax
import * as fs from "fs/promises";
import { join, resolve, dirname, basename, extname } from "path";
Path Manipulation #
Always use the path module to manipulate file paths — never concatenate paths manually with strings because the behavior differs on Windows (\) and Unix (/).
import path from "path";
// ANTI-PATTERN: concatenating paths with strings
const filePath = "data" + "/" + "config" + "/" + "app.json"; // ✗ not cross-platform
// CORRECT: use path.join()
const filePathBenar = path.join("data", "config", "app.json"); // ✓
// path.join — combine path segments, normalize separators
console.log(path.join("/home/user", "documents", "file.txt"));
// "/home/user/documents/file.txt"
console.log(path.join("/home/user", "../", "documents"));
// "/home/documents" — .. is resolved automatically
// path.resolve — create an absolute path from the current working directory
console.log(path.resolve("config", "app.json"));
// "/home/user/project/config/app.json" (depends on cwd)
console.log(path.resolve("/tmp", "config", "app.json"));
// "/tmp/config/app.json" — an absolute path stays absolute
// path.dirname — the directory of a path
console.log(path.dirname("/home/user/file.txt")); // "/home/user"
// path.basename — the file name of a path
console.log(path.basename("/home/user/file.txt")); // "file.txt"
console.log(path.basename("/home/user/file.txt", ".txt")); // "file" (without the extension)
// path.extname — the file extension
console.log(path.extname("document.pdf")); // ".pdf"
console.log(path.extname("archive.tar.gz")); // ".gz"
console.log(path.extname("Makefile")); // "" (no extension)
// path.parse — break a path into components
const parsed = path.parse("/home/user/document.pdf");
console.log(parsed);
// {
// root: "/",
// dir: "/home/user",
// base: "document.pdf",
// ext: ".pdf",
// name: "document"
// }
// path.format — the reverse of parse
console.log(path.format({ dir: "/home/user", name: "document", ext: ".pdf" }));
// "/home/user/document.pdf"
// __dirname in ES modules — not available directly, use this
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Reading Files #
Read the Whole File into Memory #
For small to medium files, readFile is the most practical way.
import { promises as fs } from "fs";
import path from "path";
// read as a string (with encoding)
async function bacaFileTeks(filePath: string): Promise<string> {
const isiFile = await fs.readFile(filePath, "utf-8");
return isiFile;
}
// read as a Buffer (for binary files: images, PDFs, etc.)
async function bacaFileBiner(filePath: string): Promise<Buffer> {
return fs.readFile(filePath); // without encoding — returns a Buffer
}
// read and parse JSON — a very common pattern
async function bacaJSON<T>(filePath: string): Promise<T> {
const isi = await fs.readFile(filePath, "utf-8");
return JSON.parse(isi) as T;
}
// example usage
interface Konfigurasi {
host: string;
port: number;
database: string;
}
const config = await bacaJSON<Konfigurasi>(
path.join(__dirname, "config", "database.json")
);
console.log(`Connected to ${config.host}:${config.port}`);
// read many files at once — faster than a sequential loop
async function bacaBanyakFile(paths: string[]): Promise<string[]> {
// Promise.all — all files are read in parallel
return Promise.all(paths.map((p) => fs.readFile(p, "utf-8")));
}
// ANTI-PATTERN: reading files sequentially in a loop
async function bacaFileSatuSatu(paths: string[]): Promise<string[]> {
const hasil: string[] = [];
for (const p of paths) {
hasil.push(await fs.readFile(p, "utf-8")); // ✗ waits one by one
}
return hasil;
}
Checking Whether a File Exists #
// ANTI-PATTERN: checking with a try/catch around readFile
async function fileAdaSalah(filePath: string): Promise<boolean> {
try {
await fs.readFile(filePath); // ✗ reading the file just to check existence — wasteful
return true;
} catch {
return false;
}
}
// CORRECT: use access() or stat()
async function fileAda(filePath: string): Promise<boolean> {
try {
await fs.access(filePath); // ✓ only checks accessibility, doesn't read contents
return true;
} catch {
return false;
}
}
// stat — detailed information about a file or directory
async function infoFile(filePath: string) {
const stat = await fs.stat(filePath);
return {
ukuranBytes: stat.size,
ukuranKB: (stat.size / 1024).toFixed(2),
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
dibuat: stat.birthtime,
dimodifikasi: stat.mtime,
diakses: stat.atime,
};
}
Writing Files #
Write a New File or Overwrite #
// writeFile — write a string or Buffer to a file
// if the file already exists, its contents are fully overwritten
async function tulisFile(filePath: string, konten: string): Promise<void> {
// create the directory if it doesn't exist
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, konten, "utf-8");
}
// write JSON with formatting
async function tulisJSON(filePath: string, data: unknown): Promise<void> {
const json = JSON.stringify(data, null, 2); // 2-space indentation
await fs.writeFile(filePath, json, "utf-8");
}
// write a Buffer (binary file)
async function tulisBiner(filePath: string, data: Buffer): Promise<void> {
await fs.writeFile(filePath, data);
}
// writeFile with additional options
await fs.writeFile("output.txt", "file contents", {
encoding: "utf-8",
flag: "w", // "w" = write (default), "a" = append, "wx" = write, error if it exists
mode: 0o644, // Unix permissions: owner rw, group r, others r
});
Append — Add to an Existing File #
// appendFile — add content to the end of a file
// creates a new file if it doesn't exist
async function tambahLog(
logPath: string,
pesan: string,
level: "INFO" | "WARN" | "ERROR" = "INFO"
): Promise<void> {
const timestamp = new Date().toISOString();
const baris = `[${timestamp}] [${level}] ${pesan}\n`;
await fs.appendFile(logPath, baris, "utf-8");
}
// usage
await tambahLog("app.log", "Server started on port 3000");
await tambahLog("app.log", "Database connection failed", "ERROR");
Atomic Writes — Preventing Data Corruption #
Writing directly to the target file is risky — if the process stops mid-write, the file can be corrupted. The safe pattern is writing to a temporary file first, then renaming.
import { randomUUID } from "crypto";
// ANTI-PATTERN: writing directly to the target file
async function simpanKonfigSalah(filePath: string, data: unknown): Promise<void> {
await fs.writeFile(filePath, JSON.stringify(data, null, 2)); // ✗ if it crashes here, the file is corrupted
}
// CORRECT: atomic write via a temp file + rename
async function simpanKonfigAman(filePath: string, data: unknown): Promise<void> {
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
try {
// write to the temporary file
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8");
// atomic rename — on Unix, rename() is an atomic operation
await fs.rename(tmpPath, filePath);
} catch (error) {
// clean up the temp file if there's an error
await fs.unlink(tmpPath).catch(() => {}); // ignore the error if tmp doesn't exist
throw error;
}
}
Directory Operations #
// create a directory (including missing parents)
await fs.mkdir("data/logs/2024", { recursive: true });
// recursive: true — no error if the directory already exists
// read a directory's contents
async function listFile(dirPath: string): Promise<string[]> {
return fs.readdir(dirPath);
}
// read with types — distinguish files and subdirectories
async function listDetail(dirPath: string) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
return {
files: entries
.filter((e) => e.isFile())
.map((e) => e.name),
directories: entries
.filter((e) => e.isDirectory())
.map((e) => e.name),
};
}
// read a directory recursively — list all files in all subdirectories
async function listFileRekursif(dirPath: string): Promise<string[]> {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const subFiles = await listFileRekursif(fullPath);
files.push(...subFiles);
} else {
files.push(fullPath);
}
}
return files;
}
// delete a directory along with its contents
await fs.rm("data/tmp", { recursive: true, force: true });
// force: true — no error if the directory doesn't exist
// copy a file
await fs.copyFile("source.txt", "destination.txt");
// if the destination exists, it gets overwritten
// move / rename a file or directory
await fs.rename("old-name.txt", "new-name.txt");
// delete a file
await fs.unlink("file-to-delete.txt");
// create a symlink
await fs.symlink("target-path", "link-path");
// read a symlink — the path it points to
const target = await fs.readlink("link-path");
Streams — Processing Large Files #
When a file can reach hundreds of MB or more, reading the entire file into memory with readFile is an anti-pattern — the program can run out of RAM. Streams let you process data chunk by chunk without loading everything at once.
flowchart LR
A[(500MB File)] --> B[ReadStream\n64KB chunks]
B --> C[Transform\nprocess chunks]
C --> D[WriteStream\nwrite chunks]
D --> E[(Output File)]
style A fill:#374151,color:#fff
style E fill:#374151,color:#fffReading with Streams #
import { createReadStream } from "fs";
import { createInterface } from "readline";
// read a text file line by line — very efficient for large log or CSV files
async function* bacaPerBaris(filePath: string): AsyncGenerator<string> {
const fileStream = createReadStream(filePath, { encoding: "utf-8" });
const rl = createInterface({
input: fileStream,
crlfDelay: Infinity, // handle Windows line endings (\r\n)
});
for await (const line of rl) {
yield line;
}
}
// process a large CSV without loading it into memory
async function prosesCSVBesar(csvPath: string): Promise<void> {
let barisKe = 0;
let header: string[] = [];
for await (const baris of bacaPerBaris(csvPath)) {
barisKe++;
if (barisKe === 1) {
header = baris.split(",").map((h) => h.trim());
continue; // skip the header row
}
const nilai = baris.split(",");
const record = Object.fromEntries(
header.map((key, i) => [key, nilai[i]?.trim() ?? ""])
);
// process one record — no need to keep all of them in memory
await prosesRecord(record);
if (barisKe % 10000 === 0) {
console.log(`Processed: ${barisKe.toLocaleString()} rows`);
}
}
console.log(`Done: ${barisKe.toLocaleString()} total rows`);
}
async function prosesRecord(record: Record<string, string>): Promise<void> {
// implementation for processing a single record
}
Writing with Streams #
import { createWriteStream } from "fs";
import { Writable } from "stream";
// write large data to a file as a stream
async function tulisStreamCSV(
outputPath: string,
data: AsyncGenerator<Record<string, unknown>>,
header: string[]
): Promise<void> {
const writeStream = createWriteStream(outputPath, { encoding: "utf-8" });
// write the header
writeStream.write(header.join(",") + "\n");
for await (const row of data) {
const line = header.map((key) => {
const val = String(row[key] ?? "");
// escape values containing commas or newlines
return val.includes(",") || val.includes("\n") ? `"${val.replace(/"/g, '""')}"` : val;
}).join(",") + "\n";
// write() returns false if the buffer is full — wait for drain before continuing
const dapatLanjut = writeStream.write(line);
if (!dapatLanjut) {
await new Promise<void>((resolve) => writeStream.once("drain", resolve));
}
}
// wait until all data has been written
await new Promise<void>((resolve, reject) => {
writeStream.end((err?: Error | null) => {
if (err) reject(err);
else resolve();
});
});
}
Pipeline — Connecting Streams #
pipeline from the stream/promises module connects several streams and handles errors and cleanup automatically — far safer than connecting streams manually.
import { pipeline } from "stream/promises";
import { createReadStream, createWriteStream } from "fs";
import { createGzip, createGunzip } from "zlib";
import { Transform } from "stream";
// compress a file with gzip
async function kompresFile(input: string, output: string): Promise<void> {
await pipeline(
createReadStream(input),
createGzip(),
createWriteStream(output)
);
console.log(`File compressed: ${input} → ${output}`);
}
// decompress a gzip file
async function dekompresFile(input: string, output: string): Promise<void> {
await pipeline(
createReadStream(input),
createGunzip(),
createWriteStream(output)
);
}
// custom transform stream — turn every chunk into uppercase
function buatUppercaseTransform(): Transform {
return new Transform({
encoding: "utf-8",
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
}
// pipeline with a custom transform
async function prosesFileUppercase(input: string, output: string): Promise<void> {
await pipeline(
createReadStream(input, { encoding: "utf-8" }),
buatUppercaseTransform(),
createWriteStream(output)
);
}
stdin and stdout #
Reading Input from the Terminal #
import { createInterface } from "readline";
// read a single line of input from the user
async function tanya(pertanyaan: string): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(pertanyaan, (jawaban) => {
rl.close();
resolve(jawaban.trim());
});
});
}
// read input with validation
async function tanyaDenganValidasi(
pertanyaan: string,
validasi: (input: string) => boolean,
pesanError: string
): Promise<string> {
while (true) {
const jawaban = await tanya(pertanyaan);
if (validasi(jawaban)) return jawaban;
console.error(pesanError);
}
}
// example: a simple CLI for data input
async function inputDataUser(): Promise<void> {
const nama = await tanyaDenganValidasi(
"Enter your name: ",
(input) => input.length >= 2,
"The name must be at least 2 characters."
);
const emailInput = await tanyaDenganValidasi(
"Enter your email: ",
(input) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input),
"Invalid email format."
);
const umurStr = await tanyaDenganValidasi(
"Enter your age: ",
(input) => !isNaN(Number(input)) && Number(input) > 0,
"The age must be a positive number."
);
console.log("\nEntered data:");
console.log(`Name : ${nama}`);
console.log(`Email : ${emailInput}`);
console.log(`Age : ${umurStr} years`);
}
// read stdin line by line (for piping: cat file.txt | node script.js)
async function bacaStdinPerBaris(): Promise<string[]> {
const baris: string[] = [];
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
baris.push(line);
}
return baris;
}
Writing to stdout and stderr #
// console.log — writes to stdout with a newline
console.log("Regular message to stdout");
// process.stdout.write — writes without an automatic newline
process.stdout.write("Loading");
process.stdout.write(".");
process.stdout.write(".");
process.stdout.write(". Done!\n");
// console.error — writes to stderr
console.error("This is an error message to stderr");
// process.stderr.write — writes to stderr without a newline
process.stderr.write("Error: connection failed\n");
// a simple progress bar in the terminal
async function prosesLambat(items: string[]): Promise<void> {
const total = items.length;
for (let i = 0; i < total; i++) {
await prosesSatuItem(items[i]);
const persen = Math.round(((i + 1) / total) * 100);
const terisi = Math.round(persen / 2);
const kosong = 50 - terisi;
const bar = "█".repeat(terisi) + "░".repeat(kosong);
// \r — return to the line start without a newline (overwrites the same line)
process.stdout.write(`\r[${bar}] ${persen}% (${i + 1}/${total})`);
}
process.stdout.write("\n"); // newline when done
}
async function prosesSatuItem(_item: string): Promise<void> {
await new Promise((r) => setTimeout(r, 50)); // simulate processing
}
Watching File Changes #
import { watch } from "fs/promises";
// watch — monitor file or directory changes
async function pantauPerubahan(targetPath: string): Promise<void> {
console.log(`Watching for changes at: ${targetPath}`);
const watcher = watch(targetPath, { recursive: true });
for await (const event of watcher) {
console.log(`Event: ${event.eventType}, File: ${event.filename}`);
if (event.eventType === "change" && event.filename?.endsWith(".json")) {
console.log("JSON file changed — reloading configuration...");
// reload configuration
}
}
}
// hot config reload — a common pattern in long-running applications
class KonfigurasiHotReload {
private config: Record<string, unknown> = {};
private configPath: string;
constructor(configPath: string) {
this.configPath = configPath;
}
async muat(): Promise<void> {
const isi = await fs.readFile(this.configPath, "utf-8");
this.config = JSON.parse(isi);
console.log("Configuration loaded");
}
async mulaiWatch(): Promise<void> {
await this.muat();
const watcher = watch(this.configPath);
for await (const event of watcher) {
if (event.eventType === "change") {
try {
await this.muat();
console.log("Configuration reloaded");
} catch (error) {
console.error("Failed to reload configuration:", error);
// keep the old configuration active
}
}
}
}
get<T>(key: string): T {
return this.config[key] as T;
}
}
I/O Error Handling #
I/O errors in Node.js have an errno code that can be used to determine the right handling.
import { constants } from "fs";
// the most common error codes
// ENOENT — file or directory not found
// EACCES — no access permission
// EEXIST — the file/directory already exists
// EISDIR — the target is a directory, not a file
// ENOSPC — disk full
// EMFILE — too many open files
async function bacaFileAman(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf-8");
} catch (error) {
if (error instanceof Error && "code" in error) {
const kodeError = (error as NodeJS.ErrnoException).code;
switch (kodeError) {
case "ENOENT":
console.warn(`File not found: ${filePath}`);
return null;
case "EACCES":
console.error(`No permission to read: ${filePath}`);
return null;
case "EISDIR":
console.error(`The target is a directory, not a file: ${filePath}`);
return null;
default:
console.error(`Error reading file (${kodeError}): ${filePath}`);
throw error; // re-throw unknown errors
}
}
throw error;
}
}
// a helper for operations that might fail due to race conditions
async function tulisFileDenganRetry(
filePath: string,
konten: string,
maxRetry = 3
): Promise<void> {
for (let i = 0; i < maxRetry; i++) {
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, konten, "utf-8");
return;
} catch (error) {
if (error instanceof Error && "code" in error) {
const kode = (error as NodeJS.ErrnoException).code;
if (kode === "ENOSPC") throw error; // disk full — retrying is pointless
if (i === maxRetry - 1) throw error; // already retried the maximum
}
// wait briefly before retrying
await new Promise((r) => setTimeout(r, 100 * (i + 1)));
}
}
}
// check access permissions before an operation
async function cekIzinBaca(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, constants.R_OK);
return true;
} catch {
return false;
}
}
async function cekIzinTulis(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, constants.W_OK);
return true;
} catch {
return false;
}
}
Don’t store paths that come from user input directly without validation — this opens the door to path traversal attacks. Always use
path.resolve()and verify that the resolved path is still inside the allowed directory.function validasiPath(userInput: string, baseDirAman: string): string { const resolved = path.resolve(baseDirAman, userInput); if (!resolved.startsWith(baseDirAman)) { throw new Error("Access denied: path outside the allowed directory"); } return resolved; }
Common Patterns in Real Applications #
Log File Rotation #
async function rotasiLog(
logDir: string,
namaBase: string,
maksFile: number = 7
): Promise<void> {
const files = await fs.readdir(logDir);
const logFiles = files
.filter((f) => f.startsWith(namaBase) && f.endsWith(".log"))
.sort()
.reverse(); // newest first
// delete log files that exceed the limit
for (const file of logFiles.slice(maksFile - 1)) {
await fs.unlink(path.join(logDir, file));
console.log(`Old log deleted: ${file}`);
}
// rename the active log to a timestamped log
const logAktif = path.join(logDir, `${namaBase}.log`);
if (await fileAda(logAktif)) {
const timestamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
await fs.rename(logAktif, path.join(logDir, `${namaBase}-${timestamp}.log`));
}
}
Reading and Writing Files in Parallel with a Concurrency Limit #
// process many files in parallel but with a concurrency limit
// prevents opening too many file descriptors at once
async function prosesFileDenganLimit<T>(
filePaths: string[],
handler: (path: string) => Promise<T>,
concurrency: number = 10
): Promise<T[]> {
const hasil: T[] = [];
const antrian = [...filePaths];
async function worker(): Promise<void> {
while (antrian.length > 0) {
const filePath = antrian.shift()!;
hasil.push(await handler(filePath));
}
}
// run N workers in parallel
await Promise.all(
Array.from({ length: Math.min(concurrency, filePaths.length) }, worker)
);
return hasil;
}
// example: process 1000 JSON files but open at most 10 at a time
const semuaHasil = await prosesFileDenganLimit(
await listFileRekursif("./data"),
async (filePath) => {
if (!filePath.endsWith(".json")) return null;
return bacaJSON(filePath);
},
10
);
Summary #
- Always use
fs/promiseswithasync/await— avoid the callback version (fs.readFile(path, cb)) which makes code hard to read and error-prone.- Use
path.join()andpath.resolve()for path manipulation — don’t concatenate strings manually because it’s not cross-platform.Promise.allfor parallel operations — reading many files at once is far faster than a sequential loop withawaitinside.- Streams for large files — use
readlinefor line-by-line reading andpipeline()fromstream/promisesfor connecting streams with proper error handling.- Atomic writes via a temp file + rename — prevents file corruption if the process stops mid-write.
- Catch
errnofor specific errors —ENOENT(doesn’t exist),EACCES(no permission),ENOSPC(disk full) need different handling.- Validate paths from user input — always use
path.resolve()and check that the result is still within a safe directory to prevent path traversal attacks.- Use
fs.access()notreadFileto check file existence — more efficient because it doesn’t read the file contents.- Limit concurrency when processing many files — opening too many file descriptors at once can cause
EMFILEerrors.