Sparround

Exceptions vs Result: which one and why

Working with exceptions is not a problem in single-layer code. The problem starts between layers, and the official Flutter docs list it in three points:

  • Exceptions go undocumented. Different layers and components can throw exceptions that aren't documented; the caller does not know what to catch.
  • Developers forget to catch. The compiler gives no warning, so an uncaught exception reaches the user as a crash.
  • `try/catch` blocks nest. With several sequential operations the control flow becomes unreadable.

The same page presents the Result class as the answer to all three: it forces the calling method to check for errors, reducing the number of bugs caused by uncaught exceptions.

The logic is simple: an exception is a hidden exit path (invisible in the signature), while a Result is explicit — it is in the function's return type and the compiler forces you to deal with it.

CriterionException`Result<T>`
Visible in the signature?No — `Future<Order>` says nothing about failureYes — `Future<Result<Order>>`
Does the compiler force handling?NoYes — a `switch` over a `sealed` type must be exhaustive
Three sequential operationsNested `try/catch`Sequential checks, flat flow
Code volumeLess — the happy path stays cleanMore — a check at every call
Programmer bugs (`Error`)Appropriate — the program should stopInappropriate — wrapping such a bug hides it
Layer boundariesProne to leakingStops at the boundary

There is an important distinction: in Dart, `Error` and `Exception` serve different purposes.

The official documentation of the Error class is direct: Error objects represent a program failure the programmer should have avoided; these are not errors a caller should expect or catch, and if they occur, terminating the program may be the safest response. Examples: RangeError, StateError, TypeError, AssertionError.

A practical rule follows:

  • `Result` is only for expected failures: no network, a 404, an expired token, failed validation, a declined payment.
  • Do not wrap an `Error`. Putting a RangeError inside Result.error() hides a bug: if a list index is wrong, the fix belongs in the code, not in an "something went wrong" message on screen.
  • The official Result implementation's error variant also carries the Exception type specifically — not Object. That is not an accident.

One nuance: catch (e) catches everything, including Errors. So in a repository it is better to write typed catches like on SocketException and on UnauthorizedException; a bare catch (e) belongs only at the very top level, for logging.

dart
// ══════ EXCEPTION ILƏ ══════
abstract interface class OrderRepository {
  // İmza: xəta barədə heç nə demir.
  // Hansı exception-lar atılır? Sənəddə yazılmasa — bilinmir.
  Future<Order> place(Cart cart);
}

// Notifier-də: nə tutmalı olduğunu təxmin edirsən.
Future<void> submit(Cart cart) async {
  state = const AsyncValue.loading();
  try {
    final order = await _repository.place(cart);
    state = AsyncValue.data(order);
  } on OutOfStockException catch (e) {      // bunu bilirdin
    state = AsyncValue.error(e, StackTrace.current);
  } on PaymentDeclinedException catch (e) { // bunu da
    state = AsyncValue.error(e, StackTrace.current);
  }
  // Bəs `SocketException`? Bəs `UnauthorizedException`?
  // Tutulmadı → tətbiq crash olur, ya da state loading-də qalır.
}


// ══════ RESULT ILƏ ══════
abstract interface class OrderRepository {
  // İmza: uğursuzluğun mümkün olduğunu AÇIQ deyir.
  Future<Result<Order>> place(Cart cart);
}

// Notifier-də: kompilyator hər iki halı nəzərə almağa məcbur edir.
Future<void> submit(Cart cart) async {
  state = const AsyncValue.loading();
  final result = await _repository.place(cart);

  // `sealed` tip: `switch` tam olmalıdır, yoxsa kompilyasiya xətası.
  state = switch (result) {
    Ok(:final value) => AsyncValue.data(value),
    Error(:final error) => AsyncValue.error(error, StackTrace.current),
  };
}


// ══════ ARDICIL ƏMƏLİYYATLAR: ən böyük fərq ══════
// Exception ilə: içi-içinə try/catch
Future<void> checkoutWithExceptions(Cart cart) async {
  try {
    final reserved = await _inventory.reserve(cart);
    try {
      final payment = await _payments.charge(reserved.total);
      try {
        await _orders.confirm(reserved, payment);
      } catch (e) {
        await _payments.refund(payment);   // kompensasiya
        rethrow;
      }
    } catch (e) {
      await _inventory.release(reserved);  // kompensasiya
      rethrow;
    }
  } catch (e) {
    // Burada hansı addım sındı? Bilinmir.
  }
}

// Result ilə: düz axın, hər addım aydın
Future<Result<Order>> checkoutWithResult(Cart cart) async {
  // Tam `switch` ifadəsi dəyəri çıxarır və uğursuzluqda dərhal qaytarır.
  // (`reserved` yalnız bir budaqda təyin olunur — Dart bunu qəbul edir,
  //  çünki digər budaq `return` edir.)
  final Reservation reserved;
  switch (await _inventory.reserve(cart)) {
    case Ok(:final value):
      reserved = value;
    case Error(:final error):
      return Result.error(error);
  }

  final Payment payment;
  switch (await _payments.charge(reserved.total)) {
    case Ok(:final value):
      payment = value;
    case Error(:final error):
      await _inventory.release(reserved);          // kompensasiya
      return Result.error(error);
  }

  switch (await _orders.confirm(reserved, payment)) {
    case Ok(:final value):
      return Result.ok(value);
    case Error(:final error):
      await _payments.refund(payment);             // kompensasiya
      await _inventory.release(reserved);
      return Result.error(error);
  }
}

The same scenario twice: with exceptions (what to catch is invisible) and with `Result` (the signature says everything).

`Result` is not needed everywhere. The practical division is:

  • The service layer — throws exceptions (UnauthorizedException, ServerException). Adding Result here buys nothing: a service is thin and its only caller is the repository.
  • The repository layer — catches those exceptions and returns a Result (or a Failure). This is the boundary.
  • Domain (use cases) — pass a Result through or combine several.
  • Presentation — converts a Result into AsyncValue or into state.

Practice. Convert one repository method in your project to return a Result: catch the exceptions inside the repository, return Result.ok/Result.error, and write a switch in the notifier. Then add a test: on a network failure a Result.error arrives and no exception is thrown.

Done means: no try/catch remains in the notifier and the test is green.

📚 Sources and documentation

  • Error handling with Result objectsofficialdocs.flutter.dev

    The main source for this topic: the three problems with exceptions and how `Result` addresses them.

  • Dart: the `Error` classofficialapi.dart.dev

    "A failure the programmer should have avoided" — the official statement that `Error`s are not meant to be caught.

  • Dart: error handlingofficialdart.dev

    The difference between `on` and `catch`, plus `rethrow` — the syntax of typed catching.