Exceptions #
Good error handling isn’t about preventing all errors from happening — that’s impossible. It’s about ensuring that when an error does occur, the program fails in a controlled way: providing enough information for debugging, not leaving corrupted state behind, and ideally enabling recovery without a crash. Dart distinguishes two different categories: Exception for runtime conditions that can be anticipated and handled, and Error for programmer bugs that should be fixed in the code. Mixing the two — catching Error like an Exception — is one of the most dangerous anti-patterns in Dart code.
The Exception and Error Hierarchy #
Understanding the type hierarchy is the foundation of proper error handling. In Dart, everything that can be thrown is an object, but there are two different roots with different purposes:
flowchart TD
O["Object"] --> Ex["Exception\n(handlable runtime conditions)"]
O --> Er["Error\n(programmer bugs — should not be caught)"]
O --> S["String, int, or any object\n(can be thrown but not recommended)"]
Ex --> FE["FormatException\n(parsing failed)"]
Ex --> IOE["IOException\n(I/O failed)"]
Ex --> HE["HttpException"]
Ex --> CE["Your Custom Exception"]
Er --> AE["ArgumentError\n(invalid argument)"]
Er --> RE["RangeError\n(index out of bounds)"]
Er --> SE["StateError\n(invalid state)"]
Er --> TE["TypeError\n(type mismatch)"]
Er --> SOE["StackOverflowError"]
Er --> OOM["OutOfMemoryError"]The main rule to always hold on to:
// Exception — anticipated runtime conditions
// Examples: file not found, bad JSON format, connection timeout
// → WORTH catching and recovering from
// Error — programmer bugs: bad arguments, out-of-bounds index
// → NOT worth catching routinely, must be fixed in the code
try, on, catch, finally
#
Dart uses four keywords for exception handling. on is used to catch specific types, catch to get the exception object and stack trace:
void prosesFile(String path) {
try {
// Code that may throw an exception
final isi = File(path).readAsStringSync();
final data = jsonDecode(isi); // may throw FormatException
simpanData(data);
} on FileSystemException catch (e) {
// Catch a specific type with an exception variable
print('File not found: ${e.path}');
print('Message: ${e.message}');
} on FormatException catch (e, stackTrace) {
// e is the exception, stackTrace is the call stack
print('Invalid JSON: ${e.message}');
print('Offset: ${e.offset}');
// Log the stack trace for debugging
print(stackTrace);
} on Exception catch (e) {
// Catch all Exceptions not specific above
print('Unknown exception: $e');
} catch (e, stackTrace) {
// Catch EVERYTHING that was thrown, including Error
// ⚠ Careful: this also catches Errors that shouldn't be caught
print('Unexpected error: $e');
} finally {
// Always executes — whether there's an exception or not
// Ideal for cleanup: close files, release connections, etc.
print('Process finished');
}
}
on Order Matters
#
on blocks are evaluated from top to bottom — the most specific must be written first:
// ANTI-PATTERN: general Exception above specific ones — the specific never runs
try {
// ...
} on Exception catch (e) { // ✗ catches ALL Exceptions including...
print('General exception: $e');
} on FormatException catch (e) { // ✗ never reached
print('Bad format: $e');
}
// CORRECT: specific on top, general below
try {
// ...
} on FormatException catch (e) { // ✓ specific — caught first
print('Bad format: ${e.message}');
} on IOException catch (e) { // ✓ still specific
print('I/O error: $e');
} on Exception catch (e) { // ✓ fallback for other Exceptions
print('Other exception: $e');
}
finally for Cleanup
#
finally runs always — even if there’s a return inside try or catch. This makes it the ideal place for cleanup operations:
IOSink? output;
try {
output = File('laporan.txt').openWrite();
output.writeln('Header');
output.writeln(hasilQuery());
return true; // finally still runs before the return!
} on FileSystemException catch (e) {
print('Failed to write: $e');
return false;
} finally {
// Always closed even with a return or exception
await output?.flush();
await output?.close();
}
throw — Throwing Exceptions
#
throw raises an exception that disrupts the normal flow of execution. Use the most specific, descriptive exception type:
// Commonly used built-in throws
void validasiUmur(int umur) {
if (umur < 0) throw ArgumentError.value(umur, 'umur', 'Must be non-negative');
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 cannot be empty');
}
String parseJson(String input) {
if (!input.trim().startsWith('{') && !input.trim().startsWith('[')) {
throw FormatException('Not valid JSON', input, 0);
}
return jsonDecode(input);
}
// throw can be used anywhere — including in expressions
String ambilNilai(Map<String, dynamic> map, String kunci) =>
map[kunci] as String? ?? (throw StateError('Key "$kunci" does not exist'));
// ANTI-PATTERN: throwing a String or primitive type
throw 'An error occurred'; // ✗ no type, no meaningful stack trace
throw 42; // ✗ meaningless entirely
// ANTI-PATTERN: throwing Error for an anticipated runtime condition
void cekKoneksi(String host) {
if (!host.contains('.')) {
throw ArgumentError('Invalid host'); // ✓ ArgumentError for a bad argument
}
// not:
// throw Error(); // ✗ too generic
}
// CORRECT: throw the most descriptive Exception
throw FormatException('Invalid date format, use YYYY-MM-DD', input);
throw ArgumentError.value(nilai, 'nilai', 'Must be between 0 and 100');
throw StateError('Connection not open — call connect() first');
rethrow — Re-throwing with an Intact Stack Trace
#
rethrow differs from throw e — it preserves the original stack trace so debugging is easier:
void ambilDataDariCache(String kunci) {
try {
return _cache.ambil(kunci);
} on CacheException catch (e) {
_log.warning('Cache miss for key: $kunci', e);
rethrow; // ✓ the original stack trace is preserved — callers can see the error origin
}
}
// Compare with throw, which corrupts the stack trace:
void ambilDataDariCacheBuruk(String kunci) {
try {
return _cache.ambil(kunci);
} on CacheException catch (e) {
throw e; // ✗ stack trace is updated to this line — the error origin is lost
}
}
// Common pattern: log then rethrow
Future<Pengguna> ambilPengguna(String id) async {
try {
return await _repository.ambilById(id);
} on DatabaseException catch (e, stackTrace) {
// Log with additional context
_logger.error(
'Failed to fetch user $id',
error: e,
stackTrace: stackTrace,
);
rethrow; // Let the caller decide how to handle it
}
}
Custom Exceptions #
When Dart’s built-ins aren’t descriptive enough for your business domain, create custom exceptions. A good exception communicates enough context for debugging without exposing implementation details:
// Basic custom exception
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 — could not connect to $host:$port$extra';
}
}
// Custom exception hierarchy for a complex domain
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: 'Insufficient balance');
double get kekurangan => jumlahDiminta - saldoTersedia;
}
class KartuDitolakException extends PembayaranException {
final String kodeBank;
const KartuDitolakException({
required super.idTransaksi,
required this.kodeBank,
}) : super(pesan: 'Card rejected by the bank');
}
class LimitHarianTercapaiException extends PembayaranException {
final double limitHarian;
const LimitHarianTercapaiException({
required super.idTransaksi,
required this.limitHarian,
}) : super(pesan: 'Daily transaction limit reached');
}
// Usage — specific handling per type
Future<void> prosesPembayaran(Pembayaran p) async {
try {
await _gateway.proses(p);
} on SaldoTidakCukupException catch (e) {
await tampilkanDialog('Insufficient balance, short Rp${e.kekurangan.toStringAsFixed(0)}');
} on KartuDitolakException catch (e) {
await tampilkanDialog('Card rejected (bank code: ${e.kodeBank})');
} on LimitHarianTercapaiException catch (e) {
await tampilkanDialog('Daily limit of Rp${e.limitHarian.toStringAsFixed(0)} reached');
} on PembayaranException catch (e) {
// Fallback for all other PembayaranExceptions
await tampilkanDialog('Payment failed: ${e.pesan}');
}
}
The Result Type Pattern — an Alternative to throw #
For operations that by design can fail and where failure is part of the normal flow (not an exceptional situation), the Result type pattern is more expressive than throw/catch. It makes the possibility of failure explicit in the function’s return type:
// Sealed Result class — 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);
}
// A function that explicitly can fail
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); // don't throw — return it as a value
}
}
// The caller is forced to handle both possibilities
Future<void> handleLogin() async {
final hasil = await loginPengguna(email, password);
// Exhaustive switch expression — the compiler ensures all cases are handled
switch (hasil) {
case Sukses(nilai: final pengguna):
navigasiKeDashboard(pengguna);
case Gagal(error: final e):
tampilkanPesanError(e.toString());
}
}
// When to use throw vs Result:
//
// USE throw when:
// - The condition is truly unexpected (bug, corrupt data, dropped connection)
// - Failure is very rare and callers don't need to handle it every time
// - Following an existing library contract (e.g. IO exceptions)
//
// USE the Result type when:
// - Failure is a normal part of the flow (failed login, failed validation)
// - You want to force callers at compile time to handle the failure case
// - Functional code that avoids the side effect of throw
Async Error Handling #
Asynchronous code has several ways to handle errors. try-catch inside an async function is the most common and readable:
// try-catch in async — the recommended way
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: 'Check your internet connection');
} on HttpException catch (e) {
throw PengambilanDataGagalException(
sumber: 'produk', statusCode: e.statusCode);
} on FormatException catch (e) {
throw DataTidakValidException(detail: e.message);
}
}
Future.catchError — Avoid for New Async Code
#
catchError is an old API from the era before async/await. For new code, use try-catch inside an async function:
// ANTI-PATTERN: catchError that's hard to read and prone to type errors
ambilData()
.then((data) => prosesData(data))
.catchError((e) => print('Error: $e'),
test: (e) => e is NetworkException) // hard-to-understand test
.catchError((e) => print('Other error: $e'));
// CORRECT: try-catch in an async function — clearer
Future<void> jalankan() async {
try {
final data = await ambilData();
prosesData(data);
} on NetworkException catch (e) {
print('Network failure: $e');
} catch (e) {
print('Other error: $e');
}
}
Handling Errors in Parallel Futures #
// ANTI-PATTERN: Future.wait without individual error handling
final hasil = await Future.wait([
ambilProduk(), // if this throws, all Futures are cancelled immediately
ambilPengguna(),
ambilOrder(),
]); // ✗ one failure → everything fails
// CORRECT: handle errors per-Future with Future.wait + eagerError: false
// or use individual try-catch
final [produk, pengguna, order] = await Future.wait([
ambilProduk().catchError((_) => <Produk>[]),
ambilPengguna().catchError((_) => null),
ambilOrder().catchError((_) => <Order>[]),
]);
// Or with more explicit error handling
final futures = await Future.wait(
[ambilProduk(), ambilPengguna(), ambilOrder()],
eagerError: false, // wait for all even if some fail
);
Error vs Exception — the Line to Hold #
This difference is one of the most misconstrued in the Dart ecosystem:
| Aspect | Exception | Error |
|---|---|---|
| Who causes it | Runtime conditions (file missing, network failure) | Programmer bugs (bad argument, invalid state) |
| Anticipatable? | ✓ Yes — part of the design | ✗ Shouldn’t be — must be fixed |
| Worth catching? | ✓ Yes — recover and continue | ✗ No — let it crash, fix the code |
| Built-in examples | FormatException, IOException | ArgumentError, RangeError, TypeError |
// Errors should NOT be caught in production code
void main() {
// ANTI-PATTERN: catching an Error to hide a bug
try {
var list = <int>[];
print(list[5]); // RangeError — programmer bug
} catch (e) {
print('Safe'); // ✗ hides the bug, the program keeps running in corrupted state
}
// CORRECT: let the Error surface — fix the code that caused it
var list = <int>[];
if (list.isNotEmpty && list.length > 5) { // validate before accessing
print(list[5]);
}
}
// When an Error MAY be caught — only at the entry point for logging
void main() {
runZonedGuarded(
() => runApp(const MyApp()),
(error, stackTrace) {
// This is the global zone for catching unhandled Errors
// Its purpose: LOG for debugging, not to recover
_logger.critical('Unhandled error', error: error, stackTrace: stackTrace);
_crashReporter.kirim(error, stackTrace);
// After logging, let the error cause a controlled crash
},
);
}
Zones and Global Error Handlers #
runZonedGuarded lets you catch all unhandled errors inside a zone — useful as a safety net at the application level:
import 'dart:async';
Future<void> main() async {
// Set up the global error handler BEFORE running the app
FlutterError.onError = (FlutterErrorDetails details) {
// Catch Flutter errors (widget errors, render errors)
_crashReporter.kirimFlutterError(details);
};
await runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize services
await _setupLogging();
await _setupCrashReporting();
runApp(const MyApp());
},
(error, stackTrace) {
// Catch unhandled async errors outside Flutter
if (error is Exception) {
// Log but don't crash
_logger.error('Unhandled exception', error: error, stackTrace: stackTrace);
} else {
// Error (bug) — log and crash gracefully
_logger.critical('Unhandled error', error: error, stackTrace: stackTrace);
_crashReporter.kirim(error, stackTrace);
}
},
);
}
Exception Handling Anti-Patterns #
Catch-All Without Rethrow #
// ANTI-PATTERN: swallowing all exceptions without action
try {
prosesData(input);
} catch (e) {
// ✗ silent — the error isn't logged, isn't rethrown, the program keeps running
// in a possibly corrupted state
}
// CORRECT: at minimum log, ideally rethrow or convert to a more appropriate type
try {
prosesData(input);
} catch (e, stackTrace) {
_logger.error('Failed to process data', error: e, stackTrace: stackTrace);
rethrow; // let the caller decide
}
Exceptions as Flow Control #
// ANTI-PATTERN: throwing to control normal flow
int cariIndeks(List<int> list, int target) {
for (int i = 0; i < list.length; i++) {
if (list[i] == target) throw FoundException(i); // ✗ try as a goto
}
return -1;
}
try {
cariIndeks(list, target);
} on FoundException catch (e) {
print('Found at index ${e.indeks}');
}
// CORRECT: return a normal value, use the right type
int? cariIndeks(List<int> list, int target) {
for (int i = 0; i < list.length; i++) {
if (list[i] == target) return i; // ✓
}
return null; // not found
}
finally That Swallows Exceptions
#
// ANTI-PATTERN: a finally that throws makes the original exception disappear
void proses() {
try {
throw FormatException('Invalid data');
} finally {
throw StateError('Cleanup failed'); // ✗ FormatException is lost, replaced by StateError
}
}
// CORRECT: finally is only for cleanup, don't throw unless truly necessary
void proses() {
IOSink? sink;
try {
sink = File('output.txt').openWrite();
throw FormatException('Invalid data');
} finally {
try {
sink?.close(); // wrap fallible cleanup in its own try
} catch (e) {
_logger.warning('Failed to close file', error: e);
// Don't rethrow here — let the original exception surface
}
}
}
Summary #
- A two-root hierarchy:
Exceptionfor anticipated, recoverable runtime conditions;Errorfor programmer bugs that must be fixed in the code, not caught at runtime.onorder from specific to general — the most specific block must be written first, or it will never be reached because the general one catches it first.finallyalways executes — even if there’s areturninsidetryorcatch. Use it for cleanup: close files, release connections, free resources.rethrowpreserves the original stack trace — unlikethrow e, which updates the stack trace to that line. Always userethrowwhen re-throwing a caught exception.- Custom exceptions must be meaningful — carry enough context information (transaction ID, host, file path) for easier debugging. Build exception hierarchies for complex domains.
- The Result type as an alternative — for operations where failure is a normal part of the business flow, a sealed
Result<T>class forces callers to handle the failure case at compile time.- try-catch in async functions is preferred over
catchError— more readable, easier to debug, and not prone to callback type errors.- Don’t catch Errors routinely —
RangeError,ArgumentError,TypeErrorare signals of bugs to fix in the code, not to hide with try-catch.runZonedGuardedas a global safety net — catch all unhandled errors at the application level for logging and crash reporting, not for recovery.- Don’t use throw as flow control — throw for genuinely exceptional conditions, use normal return values (including nullable) for regular business flow.