I/O #
Operasi I/O (Input/Output) adalah tentang bagaimana program berinteraksi dengan dunia luar — membaca dan menulis file, berkomunikasi lewat jaringan, menerima input dari pengguna. Di TypeScript, I/O hampir selalu bersifat asinkron karena sifat single-threaded JavaScript: operasi I/O yang memblokir akan membekukan seluruh event loop dan membuat aplikasi tidak responsif. Node.js menggunakan libuv untuk mendelegasikan operasi I/O ke sistem operasi secara non-blocking, sedangkan browser menggunakan Web APIs. TypeScript menambahkan lapisan type safety di atas semua ini — tipe kembalian yang akurat, penanganan error yang eksplisit, dan generics untuk response data. Memahami I/O di TypeScript berarti memahami kapan async benar-benar diperlukan, bagaimana menangani error I/O dengan benar, dan bagaimana streaming bekerja untuk data yang terlalu besar untuk dimuat sekaligus ke memori.
Async vs Sync I/O — Pilihan yang Menentukan #
Node.js menyediakan dua versi untuk hampir setiap operasi I/O: versi async (non-blocking) dan sync (blocking). Keduanya punya tempat yang tepat:
import * as fs from "fs";
import { readFile, writeFile } from "fs/promises";
// SINKRON — Memblokir event loop hingga selesai
// Kapan tepat: Script CLI satu-off, inisialisasi sebelum server dimulai
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);
}
// ASINKRON — Tidak memblokir event loop
// Kapan tepat: Hampir semua kasus dalam server/aplikasi produksi
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;
}
}
Jangan pernah gunakan operasi sync I/O di dalam server yang sedang melayani request —readFileSync,writeFileSync,execSyncsemuanya memblokir event loop dan membuat server tidak bisa melayani request lain selama operasi berlangsung. Gunakan varian async atau streaming.
Operasi File dengan fs/promises
#
fs/promises adalah modul yang mengekspos semua fungsi fs sebagai Promise — jauh lebih bersih daripada callback-based API lama:
Membaca File #
import { readFile, stat, access, constants } from "fs/promises";
import path from "path";
// Baca file teks
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}`);
}
}
}
// Baca file JSON dengan 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`);
}
}
// Baca file biner (gambar, PDF, dll)
async function bacaFileBiner(filePath: string): Promise<Buffer> {
return readFile(filePath); // Tanpa encoding = Buffer
}
// Cek apakah file ada sebelum membaca
async function fileAda(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
// Ambil informasi file
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(),
};
}
Menulis File #
import { writeFile, appendFile, mkdir, copyFile } from "fs/promises";
// Tulis file (buat baru atau timpa yang ada)
async function tulisFile(filePath: string, isi: string): Promise<void> {
// Pastikan direktori ada sebelum menulis
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, isi, "utf-8");
}
// Tulis objek sebagai JSON yang terformat
async function tulisJSON<T>(filePath: string, data: T): Promise<void> {
const json = JSON.stringify(data, null, 2); // 2 spasi indentasi
await tulisFile(filePath, json);
}
// Tambahkan ke akhir file (untuk logging, csv, dll)
async function tambahKeFile(filePath: string, baris: string): Promise<void> {
await appendFile(filePath, baris + "\n", "utf-8");
}
// Tulis dengan backup — ganti nama file lama, tulis yang baru
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");
}
Operasi Direktori #
import { readdir, rm, rename } from "fs/promises";
// Daftar semua file dalam direktori dengan 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));
}
// Daftar rekursif semua file
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;
}
// Hapus file atau direktori
async function hapus(targetPath: string): Promise<void> {
await rm(targetPath, { recursive: true, force: true });
}
// Rename / pindahkan file
async function pindahkanFile(dari: string, ke: string): Promise<void> {
await mkdir(path.dirname(ke), { recursive: true });
await rename(dari, ke);
}
Streaming — Untuk File Besar #
Membaca seluruh file ke memori dengan readFile tidak scalable untuk file besar. Streaming memproses data sepotong demi sepotong:
import { createReadStream, createWriteStream } from "fs";
import { pipeline } from "stream/promises";
import { Transform } from "stream";
// Stream baca file baris per baris — efisien untuk file log besar
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, // Tangani line ending Windows (\r\n)
});
let nomorBaris = 0;
for await (const baris of rl) {
nomorBaris++;
// Proses setiap baris tanpa memuat seluruh file ke memori
if (baris.includes("ERROR")) {
console.log(`Baris ${nomorBaris}: ${baris}`);
}
}
console.log(`Selesai memproses ${nomorBaris} baris`);
}
// Pipeline stream: baca → transformasi → tulis
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: ubah setiap chunk
const uppercase = new Transform({
transform(chunk: Buffer, _encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
// pipeline menangani error dan cleanup secara otomatis
await pipeline(readStream, uppercase, writeStream);
console.log("Transformasi selesai");
}
Path Handling yang Aman #
Menggabungkan path secara manual dengan string concatenation adalah sumber bug — terutama di Windows yang menggunakan \ sebagai separator:
import path from "path";
// ANTI-PATTERN: Concatenation manual — tidak cross-platform
const pathSalah = __dirname + "/data/" + namaFile; // Bisa salah di Windows
// BENAR: Gunakan path.join atau path.resolve
const pathBenar = path.join(__dirname, "data", namaFile);
// path.resolve — menyelesaikan ke path absolut
const pathAbsolut = path.resolve("./data", namaFile);
// Utilitas path yang berguna
console.log(path.basename("/home/user/file.txt")); // "file.txt"
console.log(path.basename("/home/user/file.txt", ".txt")); // "file" — tanpa ekstensi
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' }
// Path joining yang aman
const baseDir = "/data/proyek";
const userInput = "../../etc/passwd"; // Path traversal attack attempt!
// ANTI-PATTERN: Langsung join path dari user input
const pathBerbahaya = path.join(baseDir, userInput);
// Bisa keluar dari direktori yang diizinkan!
// BENAR: Validasi path tidak keluar dari direktori yang diizinkan
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;
}
Input Terminal dengan readline
#
import * as readline from "readline";
import { promisify } from "util";
// Interface readline standar
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Promisify rl.question untuk async/await
function tanya(pertanyaan: string): Promise<string> {
return new Promise((resolve) => {
rl.question(pertanyaan, (jawaban) => {
resolve(jawaban);
});
});
}
// Contoh: dialog interaktif multi-langkah
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}!`);
// Proses pendaftaran...
} else {
console.log("\nPendaftaran dibatalkan.");
}
} finally {
rl.close(); // Selalu tutup interface di finally
}
}
dialogPendaftaran().catch(console.error);
Fetch API — HTTP I/O yang Type-Safe #
// Tipe untuk response API
interface ResponsePaginasi<T> {
data: T[];
total: number;
halaman: number;
perHalaman: number;
}
interface Pengguna {
id: number;
nama: string;
email: string;
}
// Fetch wrapper yang type-safe dengan error handling yang benar
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 file dengan 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,
// Jangan set Content-Type — biarkan browser mengaturnya (dengan boundary)
});
if (!response.ok) throw new Error(`Upload gagal: ${response.status}`);
return response.json() as Promise<{ url: string }>;
}
// Fetch dengan 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 — Mendeteksi Perubahan File #
import { watch } from "fs";
import { stat } from "fs/promises";
// Watch sederhana — deteksi perubahan file
function pantauFile(
filePath: string,
callback: (event: string) => void
): () => void {
const watcher = watch(filePath, { persistent: false }, (event) => {
callback(event);
});
// Kembalikan fungsi untuk menghentikan watching
return () => watcher.close();
}
// Watch direktori secara rekursif
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();
}
// Penggunaan: reload konfigurasi saat file berubah
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);
}
}
});
// Hentikan watching saat aplikasi ditutup
process.on("SIGINT", () => {
hentikanPantauan();
process.exit(0);
});
Pola Alur I/O #
flowchart TD
A[Operasi I/O] --> B{Ukuran Data?}
B -- Kecil < 10MB --> C{Jenis?}
B -- Besar > 10MB --> D[Streaming\ncreateReadStream]
C -- File teks/JSON --> E[fs/promises\nreadFile writeFile]
C -- Input pengguna --> F[readline\ntanya-jawab]
C -- HTTP --> G[fetch API\ntype-safe wrapper]
C -- File biner --> H[fs/promises\ntanpa encoding]
D --> I[readline untuk teks\npipeline untuk transform]
E --> J[Error Handling\nErrnoException code]
F --> K[Tutup interface\ndi finally]
G --> L[Cek response.ok\nsebelum .json]
style D fill:#339af0,color:#fff
style J fill:#ff6b6b,color:#fff
style K fill:#fcc419,color:#000
style L fill:#ff6b6b,color:#fffRingkasan #
- Selalu gunakan async I/O di dalam server atau aplikasi yang melayani banyak pengguna —
readFileSync,writeFileSyncmemblokir event loop dan merusak concurrency; sync hanya untuk script satu-off atau inisialisasi sebelum server dimulai.fs/promiseslebih disukai darifsdengan callback — Promise-based API bekerja langsung denganasync/awaittanpa perluutil.promisify, kodenya jauh lebih bersih.- Selalu buat direktori sebelum menulis file —
mkdir(path.dirname(filePath), { recursive: true })mencegah errorENOENTsaat direktori tujuan belum ada.- Gunakan streaming untuk file besar —
createReadStreamdanreadline.Interfacememproses file baris per baris tanpa memuat seluruh file ke memori; sangat penting untuk file log, CSV besar, atau data dump.- Gunakan
path.join()bukan string concatenation —path.joinmenangani perbedaan separator di Windows (\) dan Unix (/) secara otomatis.- Validasi path dari user input — selalu cek bahwa
path.resolve(base, userInput).startsWith(path.resolve(base))untuk mencegah path traversal attack.- Cek
response.oksebelumresponse.json()— fetch tidak melempar error untuk response 4xx/5xx; status error hanya bisa dideteksi dari propertiresponse.okatauresponse.status.- Gunakan
AbortControlleruntuk memberi timeout pada fetch request — tanpanya, request bisa menggantung selamanya jika server tidak merespons.- Tangani
NodeJS.ErrnoExceptiondengan switch pada properti.code—ENOENT(tidak ada file),EACCES(tidak ada izin),EISDIR(target adalah direktori) semua butuh penanganan berbeda.