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:
Exceptionuntuk kondisi runtime yang bisa diantisipasi dan dipulihkan;Erroruntuk bug programmer yang harus diperbaiki di kode, bukan ditangkap di runtime.- Urutan
ondari spesifik ke umum — blok yang paling spesifik harus ditulis lebih awal, atau ia tidak akan pernah tercapai karena yang umum menangkapnya duluan.finallyselalu dieksekusi — bahkan jika adareturndi dalamtryataucatch. Gunakan untuk cleanup: tutup file, lepas koneksi, release resource.rethrowmenjaga stack trace asli — berbeda darithrow eyang memperbarui stack trace ke baris tersebut. Selalu gunakanrethrowjika 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 rutin —
RangeError,ArgumentError,TypeErroradalah sinyal bug yang harus diperbaiki di kode, bukan disembunyikan dengan try-catch.runZonedGuardeduntuk 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.