Eksepsi

Eksepsi #

Penanganan error yang baik bukan tentang mencegah semua error terjadi — itu tidak mungkin. Ia tentang memastikan bahwa ketika error terjadi, program gagal dengan cara yang terkontrol: memberikan informasi yang cukup untuk debugging, tidak meninggalkan state yang korup, dan idealnya memungkinkan pemulihan tanpa crash. Dart membedakan dua kategori berbeda: Exception untuk kondisi runtime yang bisa diantisipasi dan ditangani, dan Error untuk bug programmer yang seharusnya diperbaiki di kode. Mencampurkan keduanya — menangkap Error seperti Exception — adalah salah satu anti-pattern paling berbahaya dalam kode Dart.

Hierarki Exception dan Error #

Memahami hierarki tipe adalah fondasi dari penanganan error yang tepat. Di Dart, semua yang bisa di-throw adalah objek, tapi ada dua akar hierarki yang berbeda dengan tujuan berbeda pula:

flowchart TD
    O["Object"] --> Ex["Exception\n(kondisi runtime yang bisa ditangani)"]
    O --> Er["Error\n(bug programmer — tidak seharusnya ditangkap)"]
    O --> S["String, int, atau objek apapun\n(bisa di-throw tapi tidak direkomendasikan)"]

    Ex --> FE["FormatException\n(parsing gagal)"]
    Ex --> IOE["IOException\n(I/O gagal)"]
    Ex --> HE["HttpException"]
    Ex --> CE["Custom Exception kamu"]

    Er --> AE["ArgumentError\n(argumen tidak valid)"]
    Er --> RE["RangeError\n(indeks di luar batas)"]
    Er --> SE["StateError\n(state tidak valid)"]
    Er --> TE["TypeError\n(tipe tidak cocok)"]
    Er --> SOE["StackOverflowError"]
    Er --> OOM["OutOfMemoryError"]

Aturan utama yang harus selalu dipegang:

// Exception — kondisi runtime yang bisa diantisipasi
// Contoh: file tidak ditemukan, format JSON salah, koneksi timeout
// → LAYAK ditangkap dan dipulihkan

// Error — bug programmer: argumen salah, indeks di luar batas
// → TIDAK LAYAK ditangkap secara rutin, harus diperbaiki di kode

try, on, catch, finally #

Dart menggunakan empat kata kunci untuk penanganan eksepsi. on digunakan untuk menangkap tipe spesifik, catch untuk mendapatkan objek eksepsi dan stack trace:

void prosesFile(String path) {
  try {
    // Kode yang mungkin melempar eksepsi
    final isi = File(path).readAsStringSync();
    final data = jsonDecode(isi); // mungkin FormatException
    simpanData(data);

  } on FileSystemException catch (e) {
    // Tangkap tipe spesifik dengan variabel eksepsi
    print('File tidak ditemukan: ${e.path}');
    print('Pesan: ${e.message}');

  } on FormatException catch (e, stackTrace) {
    // e adalah eksepsi, stackTrace adalah call stack
    print('JSON tidak valid: ${e.message}');
    print('Offset: ${e.offset}');
    // Log stack trace untuk debugging
    print(stackTrace);

  } on Exception catch (e) {
    // Tangkap semua Exception yang tidak spesifik di atas
    print('Eksepsi tidak dikenal: $e');

  } catch (e, stackTrace) {
    // Tangkap SEMUA yang di-throw termasuk Error
    // ⚠ Hati-hati: ini juga menangkap Error yang seharusnya tidak ditangkap
    print('Error tidak terduga: $e');

  } finally {
    // Selalu dieksekusi — baik ada eksepsi atau tidak
    // Ideal untuk cleanup: tutup file, lepas koneksi, dll.
    print('Proses selesai');
  }
}

Urutan on Penting #

Blok on dievaluasi dari atas ke bawah — yang paling spesifik harus ditulis lebih dulu:

// ANTI-PATTERN: Exception umum di atas yang spesifik — yang spesifik tidak pernah tercapai
try {
  // ...
} on Exception catch (e) {          // ✗ menangkap SEMUA Exception termasuk...
  print('Eksepsi umum: $e');
} on FormatException catch (e) {    // ✗ tidak pernah sampai sini
  print('Format salah: $e');
}

// BENAR: spesifik di atas, umum di bawah
try {
  // ...
} on FormatException catch (e) {    // ✓ spesifik — ditangkap duluan
  print('Format salah: ${e.message}');
} on IOException catch (e) {        // ✓ masih spesifik
  print('I/O error: $e');
} on Exception catch (e) {          // ✓ fallback untuk Exception lain
  print('Eksepsi lain: $e');
}

finally untuk Pembersihan #

finally dijalankan selalu — bahkan jika ada return di dalam try atau catch. Ini menjadikannya tempat ideal untuk operasi cleanup:

IOSink? output;
try {
  output = File('laporan.txt').openWrite();
  output.writeln('Header');
  output.writeln(hasilQuery());
  return true; // finally tetap dijalankan sebelum return!

} on FileSystemException catch (e) {
  print('Gagal menulis: $e');
  return false;

} finally {
  // Selalu ditutup meski ada return atau exception
  await output?.flush();
  await output?.close();
}

throw — Melempar Eksepsi #

throw melempar eksepsi yang mengganggu aliran normal eksekusi. Gunakan tipe eksepsi yang paling spesifik dan deskriptif:

// throw bawaan Dart yang umum digunakan
void validasiUmur(int umur) {
  if (umur < 0) throw ArgumentError.value(umur, 'umur', 'Harus non-negatif');
  if (umur > 150) throw RangeError.range(umur, 0, 150, 'umur');
}

void prosesData(List<String>? data) {
  if (data == null) throw ArgumentError.notNull('data');
  if (data.isEmpty) throw StateError('Data tidak boleh kosong');
}

String parseJson(String input) {
  if (!input.trim().startsWith('{') && !input.trim().startsWith('[')) {
    throw FormatException('Bukan JSON valid', input, 0);
  }
  return jsonDecode(input);
}

// throw bisa digunakan di mana saja — termasuk ekspresi
String ambilNilai(Map<String, dynamic> map, String kunci) =>
    map[kunci] as String? ?? (throw StateError('Kunci "$kunci" tidak ada'));
// ANTI-PATTERN: throw String atau tipe primitif
throw 'Terjadi kesalahan';         // ✗ tidak ada tipe, tidak ada stack trace yang bermakna
throw 42;                          // ✗ tidak bermakna sama sekali

// ANTI-PATTERN: throw Error untuk kondisi runtime yang bisa diantisipasi
void cekKoneksi(String host) {
  if (!host.contains('.')) {
    throw ArgumentError('Host tidak valid'); // ✓ ArgumentError untuk argumen salah
  }
  // bukan:
  // throw Error(); // ✗ terlalu generik
}

// BENAR: throw Exception yang paling deskriptif
throw FormatException('Format tanggal salah, gunakan YYYY-MM-DD', input);
throw ArgumentError.value(nilai, 'nilai', 'Harus antara 0 dan 100');
throw StateError('Koneksi belum dibuka — panggil connect() terlebih dahulu');

rethrow — Lempar Ulang dengan Stack Trace Utuh #

rethrow berbeda dari throw e — ia melestarikan stack trace asli sehingga debugging lebih mudah:

void ambilDataDariCache(String kunci) {
  try {
    return _cache.ambil(kunci);
  } on CacheException catch (e) {
    _log.warning('Cache miss untuk kunci: $kunci', e);
    rethrow; // ✓ stack trace asli terjaga — pemanggil bisa lihat origin error
  }
}

// Bandingkan dengan throw yang merusak stack trace:
void ambilDataDariCacheBuruk(String kunci) {
  try {
    return _cache.ambil(kunci);
  } on CacheException catch (e) {
    throw e; // ✗ stack trace diperbarui ke baris ini — origin error hilang
  }
}
// Pola umum: log lalu rethrow
Future<Pengguna> ambilPengguna(String id) async {
  try {
    return await _repository.ambilById(id);
  } on DatabaseException catch (e, stackTrace) {
    // Log dengan konteks tambahan
    _logger.error(
      'Gagal mengambil pengguna $id',
      error: e,
      stackTrace: stackTrace,
    );
    rethrow; // Biarkan pemanggil memutuskan cara menanganinya
  }
}

Eksepsi Kustom #

Saat bawaan Dart tidak cukup deskriptif untuk domain bisnismu, buat eksepsi kustom. Eksepsi yang baik mengkomunikasikan konteks yang cukup untuk debugging tanpa mengekspos detail implementasi:

// Eksepsi kustom dasar
class KoneksiGagalException implements Exception {
  final String host;
  final int port;
  final String? pesanTambahan;

  const KoneksiGagalException({
    required this.host,
    required this.port,
    this.pesanTambahan,
  });

  @override
  String toString() {
    final extra = pesanTambahan != null ? ': $pesanTambahan' : '';
    return 'KoneksiGagalException — tidak dapat terhubung ke $host:$port$extra';
  }
}

// Hierarki eksepsi kustom untuk domain yang kompleks
abstract class PembayaranException implements Exception {
  final String idTransaksi;
  final String pesan;

  const PembayaranException({required this.idTransaksi, required this.pesan});

  @override
  String toString() => '${runtimeType}[$idTransaksi]: $pesan';
}

class SaldoTidakCukupException extends PembayaranException {
  final double saldoTersedia;
  final double jumlahDiminta;

  const SaldoTidakCukupException({
    required super.idTransaksi,
    required this.saldoTersedia,
    required this.jumlahDiminta,
  }) : super(pesan: 'Saldo tidak cukup');

  double get kekurangan => jumlahDiminta - saldoTersedia;
}

class KartuDitolakException extends PembayaranException {
  final String kodeBank;

  const KartuDitolakException({
    required super.idTransaksi,
    required this.kodeBank,
  }) : super(pesan: 'Kartu ditolak oleh bank');
}

class LimitHarianTercapaiException extends PembayaranException {
  final double limitHarian;

  const LimitHarianTercapaiException({
    required super.idTransaksi,
    required this.limitHarian,
  }) : super(pesan: 'Limit transaksi harian tercapai');
}

// Penggunaan — penanganan spesifik per tipe
Future<void> prosesPembayaran(Pembayaran p) async {
  try {
    await _gateway.proses(p);
  } on SaldoTidakCukupException catch (e) {
    await tampilkanDialog('Saldo kurang Rp${e.kekurangan.toStringAsFixed(0)}');
  } on KartuDitolakException catch (e) {
    await tampilkanDialog('Kartu ditolak (kode bank: ${e.kodeBank})');
  } on LimitHarianTercapaiException catch (e) {
    await tampilkanDialog('Limit harian Rp${e.limitHarian.toStringAsFixed(0)} tercapai');
  } on PembayaranException catch (e) {
    // Fallback untuk semua PembayaranException lainnya
    await tampilkanDialog('Pembayaran gagal: ${e.pesan}');
  }
}

Pola Result Type — Alternatif untuk throw #

Untuk operasi yang secara desain bisa gagal dan kegagalannya adalah bagian dari aliran normal (bukan situasi luar biasa), pola Result type lebih ekspresif dari throw/catch. Ia membuat kemungkinan gagal terlihat eksplisit dalam tipe kembalian fungsi:

// Sealed class Result — Dart 3
sealed class Result<T> {}

class Sukses<T> extends Result<T> {
  final T nilai;
  const Sukses(this.nilai);
}

class Gagal<T> extends Result<T> {
  final Exception error;
  const Gagal(this.error);
}

// Fungsi yang secara eksplisit bisa gagal
Future<Result<Pengguna>> loginPengguna(String email, String password) async {
  try {
    final token = await _auth.login(email, password);
    final pengguna = await _repo.ambilDariToken(token);
    return Sukses(pengguna);
  } on AuthException catch (e) {
    return Gagal(e); // tidak throw — kembalikan sebagai nilai
  }
}

// Pemanggil dipaksa menangani kedua kemungkinan
Future<void> handleLogin() async {
  final hasil = await loginPengguna(email, password);

  // switch expression exhaustive — compiler memastikan semua case ditangani
  switch (hasil) {
    case Sukses(nilai: final pengguna):
      navigasiKeDashboard(pengguna);
    case Gagal(error: final e):
      tampilkanPesanError(e.toString());
  }
}
// Kapan menggunakan throw vs Result:
//
// GUNAKAN throw ketika:
//   - Kondisi benar-benar tidak terduga (bug, corrupt data, koneksi putus)
//   - Kegagalan sangat jarang dan pemanggil tidak perlu menanganinya setiap saat
//   - Mengikuti kontrak library yang sudah ada (misal: IO exceptions)
//
// GUNAKAN Result type ketika:
//   - Kegagalan adalah bagian normal dari aliran (login gagal, validasi gagal)
//   - Ingin memaksa pemanggil secara kompiler untuk menangani kasus gagal
//   - Kode fungsional yang menghindari side effect dari throw

Penanganan Error Asinkron #

Kode asinkron memiliki beberapa cara menangani error. try-catch di dalam async function adalah yang paling umum dan terbaca:

// try-catch dalam async — cara yang direkomendasikan
Future<List<Produk>> ambilProduk() async {
  try {
    final response = await http.get(Uri.parse('$baseUrl/produk'));

    if (response.statusCode != 200) {
      throw HttpException(
        'Status ${response.statusCode}',
        uri: Uri.parse('$baseUrl/produk'),
      );
    }

    final json = jsonDecode(response.body) as List;
    return json.map((e) => Produk.dariJson(e)).toList();

  } on SocketException {
    throw KoneksiGagalException(host: baseUrl, port: 443,
        pesanTambahan: 'Periksa koneksi internet');
  } on HttpException catch (e) {
    throw PengambilanDataGagalException(
        sumber: 'produk', statusCode: e.statusCode);
  } on FormatException catch (e) {
    throw DataTidakValidException(detail: e.message);
  }
}

Future.catchError — Hindari untuk Async Baru #

catchError adalah API lama dari era sebelum async/await. Untuk kode baru, gunakan try-catch dalam fungsi async:

// ANTI-PATTERN: catchError yang sulit dibaca dan rawan error tipe
ambilData()
    .then((data) => prosesData(data))
    .catchError((e) => print('Error: $e'),
                test: (e) => e is NetworkException) // test sulit dipahami
    .catchError((e) => print('Error lain: $e'));

// BENAR: try-catch di async function — lebih jelas
Future<void> jalankan() async {
  try {
    final data = await ambilData();
    prosesData(data);
  } on NetworkException catch (e) {
    print('Gagal jaringan: $e');
  } catch (e) {
    print('Error lain: $e');
  }
}

Menangani Error di Beberapa Future Paralel #

// ANTI-PATTERN: Future.wait tanpa penanganan error individual
final hasil = await Future.wait([
  ambilProduk(),     // jika ini throw, semua Future langsung dibatalkan
  ambilPengguna(),
  ambilOrder(),
]); // ✗ salah satu gagal → semua gagal

// BENAR: tangani error per-Future dengan Future.wait + eagerError: false
// atau gunakan individual try-catch
final [produk, pengguna, order] = await Future.wait([
  ambilProduk().catchError((_) => <Produk>[]),
  ambilPengguna().catchError((_) => null),
  ambilOrder().catchError((_) => <Order>[]),
]);

// Atau dengan error handling yang lebih eksplisit
final futures = await Future.wait(
  [ambilProduk(), ambilPengguna(), ambilOrder()],
  eagerError: false, // tunggu semua meski ada yang gagal
);

Error vs Exception — Garis yang Harus Dipegang #

Perbedaan ini adalah salah satu yang paling sering disalahartikan dalam ekosistem Dart:

Aspek Exception Error
Siapa yang menyebabkan Kondisi runtime (file tidak ada, jaringan gagal) Bug programmer (argumen salah, state invalid)
Bisa diantisipasi? ✓ Ya — bagian dari desain ✗ Tidak seharusnya — harus diperbaiki
Layak ditangkap? ✓ Ya — pulihkan dan lanjutkan ✗ Tidak — biarkan crash, perbaiki kode
Contoh bawaan FormatException, IOException ArgumentError, RangeError, TypeError
// Error TIDAK seharusnya ditangkap dalam kode produksi
void main() {
  // ANTI-PATTERN: menangkap Error untuk menyembunyikan bug
  try {
    var list = <int>[];
    print(list[5]); // RangeError — bug programmer
  } catch (e) {
    print('Aman'); // ✗ menyembunyikan bug, program terus berjalan dalam state korup
  }

  // BENAR: biarkan Error naik — perbaiki kode yang menyebabkannya
  var list = <int>[];
  if (list.isNotEmpty && list.length > 5) { // validasi sebelum akses
    print(list[5]);
  }
}
// Kapan Error BOLEH ditangkap — hanya di entry point untuk logging
void main() {
  runZonedGuarded(
    () => runApp(const MyApp()),
    (error, stackTrace) {
      // Ini adalah zona global untuk menangkap Error yang tidak tertangani
      // Tujuannya: LOG untuk debugging, bukan untuk recover
      _logger.critical('Error tidak tertangani', error: error, stackTrace: stackTrace);
      _crashReporter.kirim(error, stackTrace);
      // Setelah log, biarkan error menyebabkan crash yang terkontrol
    },
  );
}

Zone dan Global Error Handler #

runZonedGuarded memungkinkan menangkap semua error yang tidak tertangani di dalam sebuah zone — berguna sebagai safety net di level aplikasi:

import 'dart:async';

Future<void> main() async {
  // Setup global error handler SEBELUM menjalankan aplikasi
  FlutterError.onError = (FlutterErrorDetails details) {
    // Tangkap error Flutter (widget error, render error)
    _crashReporter.kirimFlutterError(details);
  };

  await runZonedGuarded(
    () async {
      WidgetsFlutterBinding.ensureInitialized();

      // Inisialisasi layanan
      await _setupLogging();
      await _setupCrashReporting();

      runApp(const MyApp());
    },
    (error, stackTrace) {
      // Tangkap error async yang tidak tertangani di luar Flutter
      if (error is Exception) {
        // Log tapi jangan crash
        _logger.error('Unhandled exception', error: error, stackTrace: stackTrace);
      } else {
        // Error (bug) — log dan crash dengan anggun
        _logger.critical('Unhandled error', error: error, stackTrace: stackTrace);
        _crashReporter.kirim(error, stackTrace);
      }
    },
  );
}

Anti-Pattern Penanganan Eksepsi #

Catch-All Tanpa Rethrow #

// ANTI-PATTERN: menelan semua eksepsi tanpa tindakan
try {
  prosesData(input);
} catch (e) {
  // ✗ diam — error tidak dilog, tidak di-rethrow, program terus berjalan
  // dalam state yang mungkin korup
}

// BENAR: minimal log, idealnya rethrow atau convert ke tipe yang lebih tepat
try {
  prosesData(input);
} catch (e, stackTrace) {
  _logger.error('Gagal memproses data', error: e, stackTrace: stackTrace);
  rethrow; // biarkan pemanggil memutuskan
}

Exception sebagai Flow Control #

// ANTI-PATTERN: throw untuk mengontrol aliran normal
int cariIndeks(List<int> list, int target) {
  for (int i = 0; i < list.length; i++) {
    if (list[i] == target) throw FoundException(i); // ✗ try sebagai goto
  }
  return -1;
}

try {
  cariIndeks(list, target);
} on FoundException catch (e) {
  print('Ditemukan di indeks ${e.indeks}');
}

// BENAR: return nilai normal, gunakan tipe yang tepat
int? cariIndeks(List<int> list, int target) {
  for (int i = 0; i < list.length; i++) {
    if (list[i] == target) return i; // ✓
  }
  return null; // tidak ditemukan
}

finally yang Menelan Eksepsi #

// ANTI-PATTERN: finally yang throw menyebabkan eksepsi asli hilang
void proses() {
  try {
    throw FormatException('Data tidak valid');
  } finally {
    throw StateError('Cleanup gagal'); // ✗ FormatException hilang, diganti StateError
  }
}

// BENAR: finally hanya untuk cleanup, jangan throw kecuali benar-benar perlu
void proses() {
  IOSink? sink;
  try {
    sink = File('output.txt').openWrite();
    throw FormatException('Data tidak valid');
  } finally {
    try {
      sink?.close(); // bungkus cleanup yang bisa gagal dalam try tersendiri
    } catch (e) {
      _logger.warning('Gagal menutup file', error: e);
      // Jangan rethrow di sini — biarkan eksepsi asli naik
    }
  }
}

Ringkasan #

  • Hierarki dua akar: Exception untuk kondisi runtime yang bisa diantisipasi dan dipulihkan; Error untuk bug programmer yang harus diperbaiki di kode, bukan ditangkap di runtime.
  • Urutan on dari spesifik ke umum — blok yang paling spesifik harus ditulis lebih awal, atau ia tidak akan pernah tercapai karena yang umum menangkapnya duluan.
  • finally selalu dieksekusi — bahkan jika ada return di dalam try atau catch. Gunakan untuk cleanup: tutup file, lepas koneksi, release resource.
  • rethrow menjaga stack trace asli — berbeda dari throw e yang memperbarui stack trace ke baris tersebut. Selalu gunakan rethrow jika ingin melempar ulang eksepsi yang sudah ditangkap.
  • Eksepsi kustom harus bermakna — bawa informasi konteks yang cukup (ID transaksi, host, path file) agar debugging lebih mudah. Bangun hierarki eksepsi untuk domain yang kompleks.
  • Result type sebagai alternatif — untuk operasi yang kegagalannya adalah bagian normal dari aliran bisnis, sealed class Result<T> memaksa pemanggil menangani kasus gagal secara kompiler.
  • try-catch di async function lebih disukai dari catchError — lebih terbaca, lebih mudah di-debug, dan tidak rawan kesalahan tipe callback.
  • Jangan tangkap Error secara rutinRangeError, ArgumentError, TypeError adalah sinyal bug yang harus diperbaiki di kode, bukan disembunyikan dengan try-catch.
  • runZonedGuarded untuk safety net global — tangkap semua error yang tidak tertangani di level aplikasi untuk logging dan crash reporting, bukan untuk pemulihan.
  • Jangan gunakan throw sebagai flow control — throw untuk kondisi yang benar-benar exceptional, gunakan nilai kembalian normal (termasuk nullable) untuk aliran bisnis biasa.

← Sebelumnya: Interface   Berikutnya: List →

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