karawaci.kode

2026-07-19 · 11 min

Pola Error Handling TypeScript Modern 2026

Beberapa waktu lalu saya me-review sebuah service pembayaran internal yang penuh dengan try/catch bersarang. Setiap fungsi bisa melempar apa saja, tidak ada yang tahu error apa yang mungkin muncul tanpa membaca seluruh badan fungsi, dan catch (err) di mana-mana memperlakukan err sebagai any lalu memaksa cast. Ketika ada bug validasi yang lolos ke production, penyebabnya klasik: satu jalur error terlewat ditangani, dan TypeScript sama sekali tidak membantu menangkapnya karena throw tidak pernah muncul di signature fungsi.

Sejak useUnknownInCatchVariables menjadi default dan tim makin serius soal type-safety, cara kita menangani error di TypeScript ikut bergeser. Artikel ini membahas pola error handling yang saya pakai di 2026: Result type sebagai fondasi, discriminated union untuk mengklasifikasi error, custom error class untuk yang memang perlu di-throw, dan yang paling sering diperdebatkan — kapan sebaiknya throw versus return.

Masalah dengan throw sebagai default

throw punya satu kelemahan mendasar di TypeScript: ia tidak muncul di tipe. Perhatikan fungsi ini:

function parseAmount(input: string): number {
  const n = Number(input);
  if (Number.isNaN(n)) {
    throw new Error('Input bukan angka valid');
  }
  return n;
}

Signature-nya bilang fungsi ini mengembalikan number. Tidak ada petunjuk bahwa ia bisa gagal. Pemanggil yang lupa membungkus dengan try/catch tetap lolos compile, dan error baru meledak saat runtime. TypeScript tidak punya throws seperti checked exception di Java, jadi jalur error sepenuhnya tak terlihat oleh type system.

Ditambah lagi, sejak TypeScript mengaktifkan useUnknownInCatchVariables, variabel di catch bertipe unknown, bukan any:

try {
  doSomething();
} catch (err) {
  // err bertipe unknown — Anda WAJIB narrowing dulu
  if (err instanceof Error) {
    console.error(err.message);
  }
}

Ini perubahan yang sehat, tapi menyoroti masalahnya: Anda tidak pernah benar-benar tahu tipe error yang tertangkap. Untuk error yang memang diharapkan terjadi dalam alur normal, membuatnya eksplisit di tipe kembalian jauh lebih aman.

Result type: menjadikan error bagian dari tipe

Ide Result type (dipopulerkan Rust dan bahasa fungsional) adalah membungkus hasil ke dalam satu nilai yang bisa sukses atau gagal. Bentuk paling umum di TypeScript adalah discriminated union dengan field ok:

// result.ts
export type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

// Helper konstruktor agar ringkas dipakai
export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

Sekarang tulis ulang parseAmount agar error muncul di tipe:

import { Result, ok, err } from './result';

function parseAmount(input: string): Result<number, string> {
  const n = Number(input);
  if (Number.isNaN(n)) {
    return err('Input bukan angka valid');
  }
  return ok(n);
}

Perbedaannya terasa langsung di sisi pemanggil. Karena value hanya bisa diakses setelah mengecek ok, compiler memaksa Anda menangani kemungkinan gagal:

const result = parseAmount(req.body.amount);

if (!result.ok) {
  // Di cabang ini, TypeScript tahu result.error bertipe string
  return res.status(400).json({ message: result.error });
}

// Di sini result.value dijamin number — narrowing otomatis
const amount = result.value;

Tidak ada try/catch, tidak ada error tersembunyi. Jalur gagal adalah bagian dari kontrak fungsi, dan lupa menanganinya berarti result.value tidak bisa diakses — ketahuan saat compile, bukan saat production.

Discriminated union untuk mengklasifikasi error

Error bertipe string sudah lebih baik dari throw, tapi kita bisa jauh lebih presisi. Di aplikasi nyata, satu operasi bisa gagal karena beberapa alasan yang berbeda — dan tiap alasan sering perlu penanganan berbeda (status HTTP berbeda, pesan berbeda, boleh/tidak boleh di-retry). Di sinilah discriminated union bersinar.

Definisikan tipe error domain dengan field diskriminan kind:

// errors.ts
export type TransferError =
  | { kind: 'ValidationError'; field: string; message: string }
  | { kind: 'AccountNotFound'; accountId: string }
  | { kind: 'InsufficientFunds'; available: number; requested: number }
  | { kind: 'AccountFrozen'; accountId: string };

Fungsi domain mengembalikan Result dengan error union ini:

import { Result, ok, err } from './result';
import { TransferError } from './errors';

interface TransferInput {
  from: string;
  to: string;
  amount: number;
}

async function transfer(input: TransferInput): Promise<Result<Transfer, TransferError>> {
  if (input.amount <= 0) {
    return err({ kind: 'ValidationError', field: 'amount', message: 'Jumlah harus positif' });
  }

  const source = await findAccount(input.from);
  if (!source) {
    return err({ kind: 'AccountNotFound', accountId: input.from });
  }

  if (source.frozen) {
    return err({ kind: 'AccountFrozen', accountId: input.from });
  }

  if (source.balance < input.amount) {
    return err({ kind: 'InsufficientFunds', available: source.balance, requested: input.amount });
  }

  const record = await commitTransfer(input);
  return ok(record);
}

Keuntungan terbesar muncul saat menangani error di boundary. Karena kind adalah diskriminan, compiler bisa melakukan exhaustiveness checking — memastikan Anda menangani semua varian:

function toHttpResponse(error: TransferError): { status: number; body: unknown } {
  switch (error.kind) {
    case 'ValidationError':
      return { status: 400, body: { field: error.field, message: error.message } };
    case 'AccountNotFound':
      return { status: 404, body: { message: `Rekening ${error.accountId} tidak ditemukan` } };
    case 'InsufficientFunds':
      return { status: 422, body: { message: 'Saldo tidak mencukupi' } };
    case 'AccountFrozen':
      return { status: 423, body: { message: 'Rekening sedang dibekukan' } };
    default:
      // Kalau kelak ada kind baru yang belum ditangani,
      // baris ini gagal compile — jaring pengaman exhaustiveness.
      return assertNever(error);
  }
}

function assertNever(x: never): never {
  throw new Error(`Kind error tidak tertangani: ${JSON.stringify(x)}`);
}

Trik assertNever(x: never) inilah nilai jual utamanya. Kalau suatu hari Anda menambah varian baru ke TransferError (misalnya DailyLimitExceeded) tapi lupa menanganinya di switch, TypeScript langsung menolak compile karena tipe error yang tersisa bukan lagi never. Anda mustahil “lupa” menangani sebuah error.

Custom error class: untuk yang memang perlu di-throw

Result type tidak menggantikan semua kebutuhan throw. Ada situasi di mana melempar exception tetap tepat — dan saat melempar, jangan pakai new Error(string) polos. Buat custom error class yang membawa informasi terstruktur.

// app-error.ts
export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number,
    public readonly isOperational: boolean = true,
  ) {
    super(message);
    this.name = new.target.name;
    // Perbaiki prototype chain — penting saat target < ES2015
    Object.setPrototypeOf(this, new.target.prototype);
    Error.captureStackTrace?.(this, new.target);
  }
}

export class ConfigError extends AppError {
  constructor(message: string) {
    super(message, 'CONFIG_ERROR', 500, false); // non-operational = bug/fatal
  }
}

export class ExternalServiceError extends AppError {
  constructor(service: string, cause?: unknown) {
    super(`Gagal memanggil layanan ${service}`, 'EXTERNAL_SERVICE_ERROR', 502);
    this.cause = cause; // rantai penyebab asli untuk logging
  }
}

Dua detail yang sering dilewatkan:

  • Object.setPrototypeOf: saat meng-extends Error dan target compile di bawah ES2015, instanceof bisa gagal tanpa baris ini. Ini bug klasik yang bikin catch (err) { if (err instanceof AppError) } diam-diam tidak pernah true.
  • Field isOperational: bedakan error yang diharapkan (operational, misalnya layanan eksternal timeout) dari error yang berarti bug (non-operational). Pada boundary global, error non-operational biasanya jadi sinyal untuk crash dan restart proses secara terkontrol, bukan sekadar dibalas 500 lalu lanjut seolah tidak terjadi apa-apa.

Dengan custom class, error boundary Express Anda jadi rapi dan tetap type-safe berkat narrowing:

import { AppError } from './app-error';

app.use((err: unknown, _req, res, _next) => {
  if (err instanceof AppError) {
    // err di-narrow ke AppError: code, statusCode tersedia
    logger.warn({ code: err.code, msg: err.message });
    return res.status(err.statusCode).json({ code: err.code, message: err.message });
  }

  // Error tak dikenal = anggap bug, jangan bocorkan detail ke user
  logger.error({ err }, 'Unhandled error');
  return res.status(500).json({ message: 'Terjadi kesalahan internal' });
});

Kapan throw, kapan return

Ini pertanyaan yang paling sering memancing debat di tim. Alih-alih memilih satu mazhab secara absolut, saya pakai kerangka berbasis sifat error-nya:

Sifat errorContohRekomendasi
Diharapkan, bagian alur normalInput tidak valid, resource tidak ditemukan, saldo kurangReturn (Result)
Bisa dipulihkan pemanggilRetry ke layanan lain, tampilkan pesan ke userReturn (Result)
Pelanggaran invariant / bugState mustahil, assertion gagalThrow
Fatal saat startupEnv var wajib hilang, koneksi DB gagal initThrow
Benar-benar tak terdugaOut of memory, error library pihak ketigaThrow, tangkap di boundary

Prinsip di baliknya: error yang perlu ditangani pemanggil dan sifatnya wajar sebaiknya muncul di tipe (return Result), sedangkan error yang sifatnya “seharusnya tidak pernah terjadi” sebaiknya di-throw agar cepat gagal (fail fast) dan ditangkap satu boundary terpusat di atas.

Bahaya kalau memilih salah:

  • Throw untuk error yang wajar menyembunyikan jalur gagal dari tipe, dan cepat atau lambat ada pemanggil yang lupa menangkapnya. Inilah persis bug validasi yang saya ceritakan di awal.
  • Return Result untuk bug fatal justru memaksa kode meneruskan Result yang tak berarti ke mana-mana, padahal sebenarnya proses lebih baik gagal keras. Membungkus “database connection string tidak ada” ke dalam Result hanya menambah noise.

Satu catatan penting soal konsistensi: dalam satu layer/modul, usahakan konsisten. Fungsi domain sebaiknya seragam mengembalikan Result; jangan campur aduk sebagian throw sebagian return untuk kelas error yang sama, karena itu justru menghapus keuntungan prediktabilitas yang ingin dicapai.

Menjembatani kode yang throw ke Result

Realitanya, library yang Anda pakai (driver database, fetch, parser) tetap melempar exception. Bungkus mereka di lapisan tepi agar sisa kode domain Anda tetap hidup di dunia Result. Sebuah helper tryCatch kecil sangat membantu:

export async function tryCatch<T>(
  fn: () => Promise<T>,
): Promise<Result<T, Error>> {
  try {
    const value = await fn();
    return ok(value);
  } catch (e) {
    // Normalisasi unknown -> Error
    const error = e instanceof Error ? e : new Error(String(e));
    return err(error);
  }
}

Penggunaannya menutup celah antara dunia throw dan dunia Result:

const dbResult = await tryCatch(() => db.query('SELECT ...'));
if (!dbResult.ok) {
  return err({ kind: 'DatabaseUnavailable', cause: dbResult.error.message });
}
const rows = dbResult.value;

Dengan pola ini, exception dari library dikurung di batas terluar dan segera diterjemahkan menjadi error domain yang eksplisit. Kode bisnis di dalamnya tidak perlu try/catch yang berserakan. Pendekatan validasi yang serupa — mengubah kegagalan parsing jadi Result yang type-safe — juga saya singgung di perbandingan Zod, Valibot, dan ArkType, karena validator memang salah satu sumber error “wajar” terbesar yang idealnya di-return, bukan di-throw.

Trade-off yang jujur soal Result type

Result type bukan peluru perak. Ada ongkos yang perlu diakui:

  • Verbositas: setiap pemanggil harus mengecek ok sebelum memakai value. Untuk rantai operasi yang panjang, ini bisa terasa bertele-tele dibanding try/catch tunggal. Beberapa tim menambah helper map/andThen gaya fungsional untuk merangkai Result, tapi itu menambah kurva belajar.
  • Tidak ada dukungan sintaks bawaan: tidak seperti Rust dengan operator ?, TypeScript tidak punya gula sintaks untuk propagasi Result. Anda menuliskannya manual.
  • Batas ekosistem: async/await dan library sekitarnya berbasis exception. Anda selalu butuh lapisan adapter (tryCatch) di tepi.

Karena itu, saran pragmatis saya: pakai Result untuk logika domain dan error yang diharapkan, tempat type-safety paling berharga. Biarkan throw + custom error class + satu error boundary terpusat menangani hal-hal fatal dan tak terduga. Menggabungkan keduanya jauh lebih realistis daripada memaksakan Result di setiap sudut kode.

Penutup

Error handling yang baik di TypeScript 2026 bukan soal memilih antara throw dan Result secara dogmatis, melainkan memakai alat yang tepat untuk sifat error yang tepat. Result type dan discriminated union membuat error yang diharapkan menjadi bagian dari tipe — compiler memaksa Anda menanganinya, dan exhaustiveness checking memastikan tidak ada varian yang terlewat. Custom error class membuat error yang memang perlu di-throw membawa informasi terstruktur dan bisa dibedakan dengan instanceof.

Kalau tim Anda masih hidup dengan catch (err) di mana-mana dan error yang tak pernah muncul di signature, mulailah dari satu modul: ubah fungsi domain agar mengembalikan Result<T, E> dengan error union yang eksplisit, bungkus library yang throw dengan tryCatch di tepi, dan sisakan throw untuk yang benar-benar fatal. Yang Anda dapatkan bukan sekadar kode lebih rapi, tapi jaminan dari compiler bahwa jalur error tidak lagi bisa diam-diam terlewat.

Ditulis oleh Reza Pradipta