I/O #
I/O (Input/Output) operations are about how a program interacts with the outside world — reading and writing files, communicating over the network, receiving user input. In TypeScript, I/O is almost always asynchronous because of JavaScript’s single-threaded nature: a blocking I/O operation would freeze the entire event loop and make the application unresponsive. Node.js uses libuv to delegate I/O operations to the operating system non-blockingly, while browsers use Web APIs. TypeScript adds a type-safety layer on top of all of this — accurate return types, explicit error handling, and generics for response data. Understanding I/O in TypeScript means understanding when async is really needed, how to handle I/O errors correctly, and how streaming works for data too large to load into memory all at once.
Async vs Sync I/O — A Choice That Matters #
Node.js provides two versions of almost every I/O operation: an async (non-blocking) version and a sync (blocking) version. Both have their place:
import * as fs from "fs";
import { readFile, writeFile } from "fs/promises";
// SYNCHRONOUS — Blocks the event loop until done
// When appropriate: one-off CLI scripts, initialization before the server starts
try {
const isi = fs.readFileSync("konfigurasi.json", "utf-8");
const konfig = JSON.parse(isi);
console.log("Konfigurasi dimuat:", konfig);
} catch (err) {
console.error("Gagal memuat konfigurasi:", err);
process.exit(1);
}
// ASYNCHRONOUS — Doesn't block the event loop
// When appropriate: almost every case in a server/production application
async function muatKonfigurasi(): Promise<Record<string, unknown>> {
try {
const isi = await readFile("konfigurasi.json", "utf-8");
return JSON.parse(isi) as Record<string, unknown>;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
console.warn("File konfigurasi tidak ditemukan, menggunakan default");
return {};
}
throw err;
}
}
Never use sync I/O operations inside a server that’s serving requests —readFileSync,writeFileSync,execSyncall block the event loop and make the server unable to serve other requests during the operation. Use the async variant or streaming.
File Operations with fs/promises
#
fs/promises is the module that exposes all fs functions as Promises — far cleaner than the old callback-based API:
Reading Files #
import { readFile, stat, access, constants } from "fs/promises";
import path from "path";
// Read a text file
async function bacaFileTeks(filePath: string): Promise<string> {
try {
return await readFile(filePath, "utf-8");
} catch (err) {
const error = err as NodeJS.ErrnoException;
switch (error.code) {
case "ENOENT":
throw new Error(`File tidak ditemukan: ${filePath}`);
case "EACCES":
throw new Error(`Tidak ada izin membaca: ${filePath}`);
default:
throw new Error(`Gagal membaca file: ${error.message}`);
}
}
}
// Read a JSON file with type safety
async function bacaJSON<T>(filePath: string): Promise<T> {
const isi = await readFile(filePath, "utf-8");
try {
return JSON.parse(isi) as T;
} catch {
throw new SyntaxError(`File ${filePath} bukan JSON yang valid`);
}
}
// Read a binary file (images, PDFs, etc.)
async function bacaFileBiner(filePath: string): Promise<Buffer> {
return readFile(filePath); // Without encoding = Buffer
}
// Check whether a file exists before reading
async function fileAda(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
// Get file information
async function infoFile(filePath: string) {
const info = await stat(filePath);
return {
ukuran: info.size,
dibuatPada: info.birthtime,
dimodifikasiPada: info.mtime,
adalahFile: info.isFile(),
adalahDirektori: info.isDirectory(),
};
}
Writing Files #
import { writeFile, appendFile, mkdir, copyFile } from "fs/promises";
// Write a file (create new or overwrite existing)
async function tulisFile(filePath: string, isi: string): Promise<void> {
// Make sure the directory exists before writing
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, isi, "utf-8");
}
// Write an object as formatted JSON
async function tulisJSON<T>(filePath: string, data: T): Promise<void> {
const json = JSON.stringify(data, null, 2); // 2-space indentation
await tulisFile(filePath, json);
}
// Append to the end of a file (for logging, csv, etc.)
async function tambahKeFile(filePath: string, baris: string): Promise<void> {
await appendFile(filePath, baris + "\n", "utf-8");
}
// Write with a backup — rename the old file, write the new one
async function tulisDenganBackup(
filePath: string,
isi: string
): Promise<void> {
const backupPath = `${filePath}.bak`;
if (await fileAda(filePath)) {
await copyFile(filePath, backupPath);
}
await writeFile(filePath, isi, "utf-8");
}
Directory Operations #
import { readdir, rm, rename } from "fs/promises";
// List all files in a directory with a filter
async function daftarFile(
dirPath: string,
ekstensi?: string
): Promise<string[]> {
const entri = await readdir(dirPath, { withFileTypes: true });
return entri
.filter((e) => e.isFile())
.map((e) => e.name)
.filter((nama) => !ekstensi || nama.endsWith(ekstensi));
}
// Recursively list all files
async function daftarFilesRekursif(dirPath: string): Promise<string[]> {
const entri = await readdir(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const entri_ of entri) {
const fullPath = path.join(dirPath, entri_.name);
if (entri_.isDirectory()) {
const subFiles = await daftarFilesRekursif(fullPath);
files.push(...subFiles);
} else {
files.push(fullPath);
}
}
return files;
}
// Delete a file or directory
async function hapus(targetPath: string): Promise<void> {
await rm(targetPath, { recursive: true, force: true });
}
// Rename / move a file
async function pindahkanFile(dari: string, ke: string): Promise<void> {
await mkdir(path.dirname(ke), { recursive: true });
await rename(dari, ke);
}
Streaming — For Large Files #
Reading an entire file into memory with readFile doesn’t scale for large files. Streaming processes data chunk by chunk:
import { createReadStream, createWriteStream } from "fs";
import { pipeline } from "stream/promises";
import { Transform } from "stream";
// Read a file stream line by line — efficient for large log files
import * as readline from "readline";
async function prosesFileBesar(filePath: string): Promise<void> {
const fileStream = createReadStream(filePath, { encoding: "utf-8" });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity, // Handle Windows line endings (\r\n)
});
let nomorBaris = 0;
for await (const baris of rl) {
nomorBaris++;
// Process each line without loading the whole file into memory
if (baris.includes("ERROR")) {
console.log(`Baris ${nomorBaris}: ${baris}`);
}
}
console.log(`Selesai memproses ${nomorBaris} baris`);
}
// Stream pipeline: read → transform → write
async function transformasiFile(
inputPath: string,
outputPath: string
): Promise<void> {
const readStream = createReadStream(inputPath, { encoding: "utf-8" });
const writeStream = createWriteStream(outputPath, { encoding: "utf-8" });
// Transform stream: modify every chunk
const uppercase = new Transform({
transform(chunk: Buffer, _encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
// pipeline handles errors and cleanup automatically
await pipeline(readStream, uppercase, writeStream);
console.log("Transformasi selesai");
}
Safe Path Handling #
Joining paths manually with string concatenation is a bug source — especially on Windows which uses \ as the separator:
import path from "path";
// ANTI-PATTERN: Manual concatenation — not cross-platform
const pathSalah = __dirname + "/data/" + namaFile; // Can break on Windows
// CORRECT: Use path.join or path.resolve
const pathBenar = path.join(__dirname, "data", namaFile);
// path.resolve — resolves to an absolute path
const pathAbsolut = path.resolve("./data", namaFile);
// Useful path utilities
console.log(path.basename("/home/user/file.txt")); // "file.txt"
console.log(path.basename("/home/user/file.txt", ".txt")); // "file" — without the extension
console.log(path.dirname("/home/user/file.txt")); // "/home/user"
console.log(path.extname("/home/user/file.txt")); // ".txt"
console.log(path.parse("/home/user/file.txt"));
// { root: '/', dir: '/home/user', base: 'file.txt', ext: '.txt', name: 'file' }
// Safe path joining
const baseDir = "/data/proyek";
const userInput = "../../etc/passwd"; // Path traversal attack attempt!
// ANTI-PATTERN: Directly joining a path from user input
const pathBerbahaya = path.join(baseDir, userInput);
// Can escape the allowed directory!
// CORRECT: Validate that the path doesn't escape the allowed directory
function pathAman(baseDir: string, userPath: string): string {
const resolved = path.resolve(baseDir, userPath);
if (!resolved.startsWith(path.resolve(baseDir))) {
throw new Error(`Path traversal terdeteksi: ${userPath}`);
}
return resolved;
}
Terminal Input with readline
#
import * as readline from "readline";
import { promisify } from "util";
// Standard readline interface
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Promisify rl.question for async/await
function tanya(pertanyaan: string): Promise<string> {
return new Promise((resolve) => {
rl.question(pertanyaan, (jawaban) => {
resolve(jawaban);
});
});
}
// Example: a multi-step interactive dialog
async function dialogPendaftaran(): Promise<void> {
try {
const nama = await tanya("Nama lengkap: ");
const email = await tanya("Email: ");
const konfirmasi = await tanya(`Daftar sebagai ${nama} <${email}>? (y/n): `);
if (konfirmasi.toLowerCase() === "y") {
console.log(`\nTerima kasih telah mendaftar, ${nama}!`);
// Process the registration...
} else {
console.log("\nPendaftaran dibatalkan.");
}
} finally {
rl.close(); // Always close the interface in finally
}
}
dialogPendaftaran().catch(console.error);
Fetch API — Type-Safe HTTP I/O #
// Types for the API response
interface ResponsePaginasi<T> {
data: T[];
total: number;
halaman: number;
perHalaman: number;
}
interface Pengguna {
id: number;
nama: string;
email: string;
}
// A type-safe fetch wrapper with correct error handling
async function ambilAPI<T>(
url: string,
opsi?: RequestInit
): Promise<T> {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...opsi,
});
if (!response.ok) {
const pesanError = await response.text().catch(() => "Tidak ada pesan error");
throw new Error(
`HTTP ${response.status} ${response.statusText}: ${pesanError}`
);
}
return response.json() as Promise<T>;
}
// GET request
async function ambilPengguna(id: number): Promise<Pengguna> {
return ambilAPI<Pengguna>(`https://api.example.com/pengguna/${id}`);
}
// POST request
async function buatPengguna(
data: Omit<Pengguna, "id">
): Promise<Pengguna> {
return ambilAPI<Pengguna>("https://api.example.com/pengguna", {
method: "POST",
body: JSON.stringify(data),
});
}
// Upload a file with FormData
async function unggahFile(
file: File,
deskripsi: string
): Promise<{ url: string }> {
const formData = new FormData();
formData.append("file", file);
formData.append("deskripsi", deskripsi);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
// Don't set Content-Type — let the browser set it (with the boundary)
});
if (!response.ok) throw new Error(`Upload gagal: ${response.status}`);
return response.json() as Promise<{ url: string }>;
}
// Fetch with a timeout
async function ambilDenganTimeout<T>(
url: string,
timeoutMs: number
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
} catch (err) {
if ((err as Error).name === "AbortError") {
throw new Error(`Request timeout setelah ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
File Watching — Detecting File Changes #
import { watch } from "fs";
import { stat } from "fs/promises";
// Simple watch — detect file changes
function pantauFile(
filePath: string,
callback: (event: string) => void
): () => void {
const watcher = watch(filePath, { persistent: false }, (event) => {
callback(event);
});
// Return a function to stop watching
return () => watcher.close();
}
// Recursively watch a directory
function pantauDirektori(
dirPath: string,
callback: (event: string, filename: string | null) => void
): () => void {
const watcher = watch(
dirPath,
{ recursive: true, persistent: false },
(event, filename) => {
callback(event, filename);
}
);
return () => watcher.close();
}
// Usage: reload the configuration when the file changes
const hentikanPantauan = pantauFile("konfigurasi.json", async (event) => {
if (event === "change") {
console.log("Konfigurasi berubah, memuat ulang...");
try {
const konfig = await bacaJSON<Record<string, unknown>>("konfigurasi.json");
console.log("Konfigurasi baru:", konfig);
} catch (err) {
console.error("Gagal memuat konfigurasi baru:", err);
}
}
});
// Stop watching when the application closes
process.on("SIGINT", () => {
hentikanPantauan();
process.exit(0);
});
I/O Flow Patterns #
flowchart TD
A[I/O Operation] --> B{Data Size?}
B -->|Small < 10MB| C{Type?}
B -->|Large > 10MB| D["Streaming<br/>createReadStream"]
C -->|Text/JSON file| E["fs/promises<br/>readFile writeFile"]
C -->|User input| F["readline<br/>question-answer"]
C -->|HTTP| G["fetch API<br/>type-safe wrapper"]
C -->|Binary file| H["fs/promises<br/>without encoding"]
D --> I["readline for text<br/>pipeline for transforms"]
E --> J["Error Handling<br/>ErrnoException code"]
F --> K["Close the interface<br/>in finally"]
G --> L["Check response.ok<br/>before .json"]
style D fill:#339af0,color:#fff
style J fill:#ff6b6b,color:#fff
style K fill:#fcc419,color:#000
style L fill:#ff6b6b,color:#fffSummary #
- Always use async I/O inside servers or applications serving many users —
readFileSync,writeFileSyncblock the event loop and break concurrency; sync is only for one-off scripts or initialization before the server starts.fs/promisesis preferred overfswith callbacks — the Promise-based API works directly withasync/awaitwithout needingutil.promisify, and the code is far cleaner.- Always create the directory before writing a file —
mkdir(path.dirname(filePath), { recursive: true })prevents theENOENTerror when the destination directory doesn’t exist yet.- Use streaming for large files —
createReadStreamandreadline.Interfaceprocess files line by line without loading the whole file into memory; essential for log files, large CSVs, or data dumps.- Use
path.join()instead of string concatenation —path.joinhandles the separator differences between Windows (\\) and Unix (/) automatically.- Validate paths from user input — always check that
path.resolve(base, userInput).startsWith(path.resolve(base))to prevent path traversal attacks.- Check
response.okbeforeresponse.json()— fetch doesn’t throw errors for 4xx/5xx responses; error statuses can only be detected from theresponse.okorresponse.statusproperties.- Use
AbortControllerto give fetch requests a timeout — without it, a request can hang forever if the server doesn’t respond.- Handle
NodeJS.ErrnoExceptionwith a switch on the.codeproperty —ENOENT(no file),EACCES(no permission),EISDIR(target is a directory) all need different handling.