Eksepsi #

Penanganan error adalah salah satu aspek yang paling sering ditulis dengan buruk dalam kode TypeScript — bukan karena konsepnya sulit, tapi karena banyak kebiasaan dari JavaScript yang tidak sepenuhnya aman di TypeScript. Perubahan terpenting di TypeScript 4.0 adalah bahwa variabel error di blok catch kini bertipe unknown, bukan any — ini memaksa kamu melakukan type narrowing sebelum mengakses properti error, yang merupakan praktik yang jauh lebih aman. Di luar mekanisme try-catch, TypeScript mendukung pendekatan modern seperti pola Result yang membuat error menjadi nilai biasa yang bisa dikembalikan alih-alih dilempar, membuat error handling lebih eksplisit dan lebih mudah diuji.

try-catch-finally — Dasar Penanganan Error #

try-catch-finally adalah mekanisme penanganan error utama yang diwarisi dari JavaScript. Di TypeScript dengan strict: true, variabel error di blok catch bertipe unknown — ini adalah perubahan penting dibanding JavaScript biasa:

function bagi(a: number, b: number): number {
  if (b === 0) {
    throw new Error("Pembagian dengan nol tidak diizinkan");
  }
  return a / b;
}

try {
  const hasil = bagi(10, 0);
  console.log(`Hasil: ${hasil}`);
} catch (error) {
  // Di TypeScript strict: error bertipe 'unknown', bukan 'any'

  // ANTI-PATTERN: Langsung akses properti tanpa narrowing
  // console.error(error.message); // ✗ Error: 'error' is of type 'unknown'

  // BENAR: Narrow terlebih dahulu
  if (error instanceof Error) {
    console.error(`Error: ${error.message}`);
    console.error(`Stack: ${error.stack}`);
  } else {
    // Seseorang bisa throw nilai bukan-Error, mis: throw "string error"
    console.error(`Error tidak dikenal: ${String(error)}`);
  }
} finally {
  // Selalu dieksekusi, baik ada error maupun tidak
  console.log("Operasi selesai — membersihkan resource");
}

Mengapa unknown Lebih Baik dari any #

// Dengan 'any' (JavaScript biasa) — tidak aman
try {
  riskyOperation();
} catch (error: any) {
  error.message;           // ✓ Tidak ada error kompilasi
  error.metodeTidakAda();  // ✓ Tidak ada error kompilasi — tapi crash di runtime!
}

// Dengan 'unknown' (TypeScript strict) — paksa validasi
try {
  riskyOperation();
} catch (error) {
  // error.message;        // ✗ Error kompilasi: Object is of type 'unknown'
  if (error instanceof Error) {
    error.message;         // ✓ Aman — TypeScript tahu ini Error
  }
}

finally — Pembersihan yang Dijamin #

Blok finally selalu dieksekusi — bahkan jika blok try punya return atau throw:

async function ambilDataDenganKoneksi(id: string): Promise<string> {
  const koneksi = await bukaKoneksi();

  try {
    const data = await koneksi.query(`SELECT * FROM data WHERE id = '${id}'`);
    return data;
  } catch (error) {
    if (error instanceof Error) {
      throw new Error(`Gagal mengambil data: ${error.message}`);
    }
    throw error;
  } finally {
    // Koneksi selalu ditutup — baik sukses maupun gagal
    await koneksi.tutup();
    console.log("Koneksi database ditutup");
  }
}

// Fungsi mock untuk contoh
async function bukaKoneksi() {
  return {
    query: async (sql: string) => `Data untuk query: ${sql}`,
    tutup: async () => {},
  };
}

Custom Error Class — Hierarki Error yang Terstruktur #

Melempar new Error("pesan") generik membuat penanganan error di level atas menjadi sulit — kamu tidak bisa membedakan jenis error tanpa memeriksa pesan (yang rawan typo). Solusinya adalah membuat hierarki custom error class:

// Kelas error dasar untuk aplikasi — tambahkan metadata yang berguna
class AppError extends Error {
  readonly kodeError: string;
  readonly statusHttp: number;
  readonly timestamp: Date;

  constructor(pesan: string, kodeError: string, statusHttp: number = 500) {
    super(pesan);
    this.name = this.constructor.name; // Otomatis mengisi nama kelas
    this.kodeError = kodeError;
    this.statusHttp = statusHttp;
    this.timestamp = new Date();

    // Fix untuk prototype chain di TypeScript yang dikompilasi ke ES5
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

// Error-error spesifik — masing-masing punya konteks sendiri
class ErrorValidasi extends AppError {
  readonly field: string;

  constructor(field: string, pesan: string) {
    super(pesan, "VALIDATION_ERROR", 400);
    this.field = field;
  }
}

class ErrorTidakDitemukan extends AppError {
  readonly resource: string;
  readonly idDicari: string;

  constructor(resource: string, id: string) {
    super(`${resource} dengan ID '${id}' tidak ditemukan`, "NOT_FOUND", 404);
    this.resource = resource;
    this.idDicari = id;
  }
}

class ErrorTidakDiotorisasi extends AppError {
  constructor(aksi: string) {
    super(`Tidak memiliki izin untuk: ${aksi}`, "UNAUTHORIZED", 403);
  }
}

class ErrorKoneksiDatabase extends AppError {
  readonly queryGagal?: string;

  constructor(pesan: string, query?: string) {
    super(pesan, "DATABASE_ERROR", 503);
    this.queryGagal = query;
  }
}

Menangkap Error Berdasarkan Hierarki #

async function ambilProduk(id: string, penggunaId: string): Promise<Produk> {
  // Validasi input
  if (!id.startsWith("PRD-")) {
    throw new ErrorValidasi("id", `Format ID produk tidak valid: ${id}`);
  }

  // Cek otorisasi
  const bolehLihat = await cekIzin(penggunaId, "produk:baca");
  if (!bolehLihat) {
    throw new ErrorTidakDiotorisasi("membaca produk");
  }

  // Ambil dari database
  const produk = await db.cariById(id);
  if (!produk) {
    throw new ErrorTidakDitemukan("Produk", id);
  }

  return produk;
}

// Handler di level controller/route
async function handlerAmbilProduk(id: string, penggunaId: string): Promise<void> {
  try {
    const produk = await ambilProduk(id, penggunaId);
    console.log("Produk ditemukan:", produk);
  } catch (error) {
    // Tangkap berdasarkan tipe — dari yang paling spesifik ke yang paling umum
    if (error instanceof ErrorValidasi) {
      console.error(`[400] Validasi gagal pada field '${error.field}': ${error.message}`);
    } else if (error instanceof ErrorTidakDiotorisasi) {
      console.error(`[403] Tidak diotorisasi: ${error.message}`);
    } else if (error instanceof ErrorTidakDitemukan) {
      console.error(`[404] ${error.resource} tidak ditemukan (ID: ${error.idDicari})`);
    } else if (error instanceof AppError) {
      console.error(`[${error.statusHttp}] ${error.kodeError}: ${error.message}`);
    } else if (error instanceof Error) {
      console.error(`[500] Error tidak terduga: ${error.message}`);
    } else {
      console.error(`[500] Error tidak diketahui:`, error);
    }
  }
}

// Fungsi-fungsi mock
async function cekIzin(userId: string, izin: string): Promise<boolean> { return true; }
const db = { cariById: async (id: string): Promise<Produk | null> => null };
interface Produk { id: string; nama: string; harga: number; }

Re-throwing — Melempar Ulang dengan Benar #

Re-throwing adalah teknik menangkap error, melakukan sesuatu (logging, enrichment), lalu melempar ulang agar ditangani di level yang lebih tinggi:

class LayananPembayaran {
  async prosesPembayaran(orderId: string, jumlah: number): Promise<string> {
    try {
      const respon = await this.panggilAPIGateway(orderId, jumlah);
      return respon.transactionId;
    } catch (error) {
      // Catat error dengan konteks yang kaya
      console.error("[LayananPembayaran] Pembayaran gagal:", {
        orderId,
        jumlah,
        waktu: new Date().toISOString(),
        error: error instanceof Error ? error.message : String(error),
      });

      // ANTI-PATTERN: Re-throw yang kehilangan konteks original
      // throw new Error("Pembayaran gagal"); // Stack trace original hilang!

      // BENAR: Wrap error dengan konteks tambahan tapi pertahankan cause
      if (error instanceof Error) {
        throw new ErrorGatewayPembayaran(
          `Pembayaran untuk order ${orderId} gagal: ${error.message}`,
          { cause: error } // ES2022 — menyimpan error original sebagai cause
        );
      }
      throw error; // Re-throw jika bukan instance Error
    }
  }

  private async panggilAPIGateway(orderId: string, jumlah: number): Promise<{ transactionId: string }> {
    throw new Error("Timeout koneksi ke payment gateway");
  }
}

class ErrorGatewayPembayaran extends AppError {
  constructor(pesan: string, options?: ErrorOptions) {
    super(pesan, "PAYMENT_GATEWAY_ERROR", 502);
    if (options?.cause instanceof Error) {
      // Akses error penyebab lewat .cause
      console.log("Penyebab:", options.cause.message);
    }
  }
}

Pola Result — Error sebagai Nilai #

Pola Result adalah alternatif untuk throw-catch yang membuat error menjadi bagian eksplisit dari tipe kembalian fungsi. Ini meminjam konsep dari bahasa seperti Rust dan Go:

// Tipe Result yang ekspresif
type Result<T, E extends Error = Error> =
  | { berhasil: true; data: T; error?: never }
  | { berhasil: false; data?: never; error: E };

// Fungsi helper untuk membuat Result
const ok = <T>(data: T): Result<T, never> => ({ berhasil: true, data });
const gagal = <E extends Error>(error: E): Result<never, E> => ({ berhasil: false, error });

// Fungsi yang mengembalikan Result alih-alih throw
async function parseJSON<T>(jsonString: string): Promise<Result<T, SyntaxError>> {
  try {
    const data = JSON.parse(jsonString) as T;
    return ok(data);
  } catch (error) {
    return gagal(error as SyntaxError);
  }
}

async function ambilKonfigurasi(path: string): Promise<Result<KonfigurasiApp, AppError>> {
  try {
    // Simulasi baca file
    const isi = await bacaFile(path);
    const hasil = await parseJSON<KonfigurasiApp>(isi);

    if (!hasil.berhasil) {
      return gagal(new AppError(
        `File konfigurasi tidak valid: ${hasil.error.message}`,
        "CONFIG_PARSE_ERROR"
      ));
    }

    return ok(hasil.data);
  } catch (error) {
    return gagal(new AppError(
      `Gagal membaca konfigurasi dari ${path}`,
      "CONFIG_READ_ERROR"
    ));
  }
}

// Penggunaan — tidak ada try-catch di level pemanggil
async function inisialisasiApp(): Promise<void> {
  const hasil = await ambilKonfigurasi("/etc/app/config.json");

  if (!hasil.berhasil) {
    console.error(`Inisialisasi gagal [${hasil.error.kodeError}]: ${hasil.error.message}`);
    process.exit(1);
  }

  // TypeScript tahu hasil.data bertipe KonfigurasiApp di sini
  console.log(`App dimulai dengan environment: ${hasil.data.environment}`);
}

// Tipe dan fungsi mock
interface KonfigurasiApp { environment: string; }
async function bacaFile(path: string): Promise<string> { return "{}"; }

Kapan Throw vs Return Result #

Gunakan throw jika:
  ✓ Error benar-benar tidak terduga (bug, infrastruktur down)
  ✓ Di lapisan bawah (utilitas, library) yang tidak tahu cara recover
  ✓ Kondisi yang "seharusnya tidak pernah terjadi" (programmer error)
  ✓ Error harus propagate jauh ke atas tanpa ditangani di tengah jalan

Gunakan Result jika:
  ✓ Error adalah bagian dari alur bisnis normal (validasi, not found)
  ✓ Pemanggil diharapkan menangani semua kemungkinan error
  ✓ Kamu ingin error handling yang terlihat di signature fungsi
  ✓ Menulis kode yang mudah diuji (tidak perlu mock throw)

Assertion Function — Validasi Prasyarat #

Assertion function melempar error jika kondisi tidak terpenuhi, dan memberi tahu TypeScript bahwa nilai tersebut punya tipe tertentu setelah assertion berhasil:

// Assertion function dasar
function pastikan(kondisi: boolean, pesan: string): asserts kondisi {
  if (!kondisi) {
    throw new Error(`Assertion gagal: ${pesan}`);
  }
}

// Assertion function dengan type narrowing
function pastikanBukanNull<T>(
  nilai: T | null | undefined,
  namaVariabel: string
): asserts nilai is T {
  if (nilai === null || nilai === undefined) {
    throw new ErrorValidasi(namaVariabel, `${namaVariabel} tidak boleh kosong`);
  }
}

// Assertion function untuk tipe spesifik
function pastikanString(nilai: unknown, namaField: string): asserts nilai is string {
  if (typeof nilai !== "string") {
    throw new ErrorValidasi(namaField, `${namaField} harus bertipe string, bukan ${typeof nilai}`);
  }
}

// Penggunaan
function prosesOrder(
  orderId: unknown,
  penggunaId: string | null,
  items: unknown[]
): void {
  // Setelah assertion, TypeScript menyempitkan tipe secara otomatis
  pastikanString(orderId, "orderId");
  // orderId: string di sini

  pastikanBukanNull(penggunaId, "penggunaId");
  // penggunaId: string (bukan string | null) di sini

  pastikan(items.length > 0, "Order harus memiliki minimal satu item");

  console.log(`Memproses order ${orderId} untuk pengguna ${penggunaId}`);
  console.log(`Jumlah item: ${items.length}`);
}

Penanganan Error Async yang Benar #

Error dalam kode async sering lolos jika tidak ditangani dengan benar. Ada beberapa jebakan yang perlu diperhatikan:

// ANTI-PATTERN: Promise yang tidak di-await — error diabaikan diam-diam
function mulaiProses(): void {
  ambilData(); // ✗ Promise tidak di-await, error bisa hilang tanpa jejak
}

// ANTI-PATTERN: try-catch yang tidak menangkap async error
async function contohSalah(): Promise<void> {
  try {
    setTimeout(async () => {
      await operasiGagal(); // ✗ Error di dalam setTimeout tidak tertangkap oleh try-catch luar
    }, 1000);
  } catch (error) {
    console.error("Error ini tidak akan tertangkap!"); // Tidak akan pernah dipanggil
  }
}

// BENAR: Selalu await Promise dalam try-catch
async function contohBenar(): Promise<void> {
  try {
    await operasiGagal(); // ✓ Error tertangkap karena di-await
  } catch (error) {
    if (error instanceof Error) {
      console.error(`Error: ${error.message}`);
    }
  }
}

// BENAR: Tangkap unhandled promise rejection secara global (Node.js)
process.on("unhandledRejection", (reason, promise) => {
  console.error("Promise rejection yang tidak ditangani:", reason);
  // Catat ke monitoring service (Sentry, Datadog, dll.)
  process.exit(1); // Keluar dengan kode error
});

async function operasiGagal(): Promise<void> {
  throw new Error("Operasi gagal");
}

Alur Penanganan Error dalam Aplikasi #

flowchart TD
    A[Error Terjadi] --> B{Jenis Error?}

    B -- Error bisnis\nvalidasi, not found --> C[Kembalikan sebagai Result\natau throw custom AppError]
    B -- Error infrastruktur\nDB, network, timeout --> D[Log + throw AppError\ndengan konteks]
    B -- Bug programmer\nassert gagal --> E[throw Error biasa\njangan tangkap]
    B -- Error eksternal\nAPI, parsing --> F[Wrap dalam custom error\npertahankan cause]

    C --> G{Di level mana?}
    D --> G
    F --> G

    G -- Lapisan bawah\nservice, repository --> H[Re-throw dengan enrichment]
    G -- Lapisan controller\nAPI handler --> I[Tangkap dan format\nke response HTTP]
    G -- Lapisan atas\nmain, entrypoint --> J[Log + exit / retry]

    H --> G
    I --> K[Kirim response\n4xx atau 5xx]
    J --> L[Monitoring + Alert]

    style C fill:#51cf66,color:#fff
    style D fill:#fcc419,color:#000
    style E fill:#ff6b6b,color:#fff
    style F fill:#339af0,color:#fff
    style K fill:#51cf66,color:#fff
    style L fill:#cc5de8,color:#fff

Ringkasan #

  • error di catch bertipe unknown — selalu lakukan instanceof Error sebelum mengakses .message atau .stack; jangan pernah cast ke any untuk menghindari narrowing karena ini mengembalikan kamu ke perilaku JavaScript yang tidak aman.
  • Object.setPrototypeOf(this, new.target.prototype) — tambahkan baris ini di constructor custom error class untuk memperbaiki prototype chain saat dikompilasi ke ES5; tanpanya, instanceof tidak akan bekerja dengan benar.
  • Hierarki custom error dari AppError dasar memungkinkan penanganan error per kategori dengan instanceof; masing-masing error spesifik bisa membawa metadata tambahan (field, resource ID, HTTP status).
  • Re-throw dengan cause (ES2022) mempertahankan stack trace original saat membungkus error dengan konteks tambahan — throw new AppError("pesan", { cause: error }) jauh lebih baik dari throw error baru yang kehilangan jejak.
  • Pola Result { berhasil: true; data: T } | { berhasil: false; error: E } membuat error menjadi bagian eksplisit dari kontrak fungsi — caller tidak bisa lupa menangani error karena TypeScript memaksa pengecekan.
  • Assertion function (asserts kondisi atau asserts nilai is T) memvalidasi prasyarat dan menyempitkan tipe secara otomatis setelah assertion berhasil — ideal untuk validasi parameter di awal fungsi.
  • Jangan mix try-catch dengan callback atau setTimeout — error di dalam callback asinkron tidak tertangkap oleh try-catch luar; selalu await Promise dalam try-catch.
  • Daftarkan global error handler dengan process.on("unhandledRejection", ...) di Node.js untuk menangkap Promise rejection yang tidak tertangani sebelum menyebabkan crash yang tak terduga.
  • Throw untuk bug dan kondisi mustahil, Result untuk error bisnis — validasi input yang gagal adalah bagian normal dari alur bisnis dan lebih baik direpresentasikan sebagai Result; crash koneksi database adalah kondisi tak terduga yang layak di-throw.

← Sebelumnya: Interface   Berikutnya: List →

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