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:#fff

Reading 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/promises with async/await — avoid the callback version (fs.readFile(path, cb)) which makes code hard to read and error-prone.
  • Use path.join() and path.resolve() for path manipulation — don’t concatenate strings manually because it’s not cross-platform.
  • Promise.all for parallel operations — reading many files at once is far faster than a sequential loop with await inside.
  • Streams for large files — use readline for line-by-line reading and pipeline() from stream/promises for connecting streams with proper error handling.
  • Atomic writes via a temp file + rename — prevents file corruption if the process stops mid-write.
  • Catch errno for specific errorsENOENT (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() not readFile to 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 EMFILE errors.

← Previous: Strings   Next: Math →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact