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:

AspectExceptionError
Who causes itRuntime 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 examplesFormatException, IOExceptionArgumentError, 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: Exception for anticipated, recoverable runtime conditions; Error for programmer bugs that must be fixed in the code, not caught at runtime.
  • on order 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.
  • finally always executes — even if there’s a return inside try or catch. Use it for cleanup: close files, release connections, free resources.
  • rethrow preserves the original stack trace — unlike throw e, which updates the stack trace to that line. Always use rethrow when 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 routinelyRangeError, ArgumentError, TypeError are signals of bugs to fix in the code, not to hide with try-catch.
  • runZonedGuarded as 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.

← Previous: Interfaces   Next: List →

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