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.
| Criterion | Exception | `Result<T>` |
|---|---|---|
| Visible in the signature? | No — `Future<Order>` says nothing about failure | Yes — `Future<Result<Order>>` |
| Does the compiler force handling? | No | Yes — a `switch` over a `sealed` type must be exhaustive |
| Three sequential operations | Nested `try/catch` | Sequential checks, flat flow |
| Code volume | Less — the happy path stays clean | More — a check at every call |
| Programmer bugs (`Error`) | Appropriate — the program should stop | Inappropriate — wrapping such a bug hides it |
| Layer boundaries | Prone to leaking | Stops 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
RangeErrorinsideResult.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
Resultimplementation's error variant also carries theExceptiontype specifically — notObject. 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.
// ══════ 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). AddingResulthere buys nothing: a service is thin and its only caller is the repository. - The repository layer — catches those exceptions and returns a
Result(or aFailure). This is the boundary. - Domain (use cases) — pass a
Resultthrough or combine several. - Presentation — converts a
ResultintoAsyncValueor 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.