Skip to content

08 · Error Handling

Async basics showed a quick try/catch around an await. This module covers the full picture: designing your own exception types, handling multiple failure kinds in the right order, finally, rethrow, and — critically for anything built on Futures or Streams — how errors propagate (or silently vanish) in async code.

Exception vs Error

Dart's throwable hierarchy distinguishes two intents. Exception (implement it, or throw a built-in like FormatException) signals an expected, recoverable failure — bad input, a network timeout, a missing file. Error (like StateError, ArgumentError, RangeError) signals a programmer mistake — something that should have been prevented by correct code, not routinely caught and handled.

class InsufficientFundsException implements Exception {
  final double requested;
  final double available;
  InsufficientFundsException(this.requested, this.available);

  @override
  String toString() =>
      'InsufficientFundsException: requested \$$requested but only \$$available available';
}

class Account {
  double _balance;
  Account(this._balance);

  void withdraw(double amount) {
    if (amount > _balance) {
      throw InsufficientFundsException(amount, _balance);
    }
    _balance -= amount;
  }
}

void main() {
  final account = Account(100);

  try {
    account.withdraw(150);
  } on InsufficientFundsException catch (e) {
    print('Handled: $e');
    // Handled: InsufficientFundsException: requested $150.0 but only $100.0 available
  }
}

implements Exception is just a marker — it doesn't require any particular members — but using it (rather than throwing a plain String or Object) communicates intent clearly and lets callers catch it specifically with on InsufficientFundsException.

finally always runs

void main() {
  final account = Account(100);

  try {
    account.withdraw(30);
    print('Withdrawal succeeded');
  } on InsufficientFundsException catch (e) {
    print('Handled: $e');
  } finally {
    print('Transaction attempt complete');
  }
  // Withdrawal succeeded
  // Transaction attempt complete
}

finally runs whether the try block succeeds, throws and gets caught, or throws and isn't caught (in which case it runs before the exception keeps propagating). It's the right place for cleanup — closing a file, releasing a lock — that must happen regardless of outcome.

Multiple catch clauses — order matters

int parseAndDouble(String input) {
  final n = int.parse(input); // throws FormatException on bad input
  return n * 2;
}

void demoMultiCatch(String input) {
  try {
    print(parseAndDouble(input));
  } on FormatException catch (e) {
    // More specific catches must come BEFORE more general ones --
    // order matters, same as in most languages.
    print('Not a number: ${e.message}');
  } on Exception catch (e) {
    print('Some other exception: $e');
  } catch (e) {
    // Bare `catch` also catches Errors (bugs), not just Exceptions (failures).
    print('Unknown throwable: $e');
  }
}

void main() {
  demoMultiCatch('42');    // 84
  demoMultiCatch('abc');   // Not a number: Invalid radix-10 number
}

Dart checks on clauses top-to-bottom and uses the first match — a bare catch (e) at the end acts as a catch-all, matching anything not already caught above it, including Errors. Put it last, and put the most specific types first.

rethrow — preserve the original stack trace

void logAndRethrow() {
  try {
    throw StateError('deep failure');
  } catch (e) {
    print('Logging before rethrow: $e');
    rethrow; // preserves the original stack trace, unlike `throw e;`
  }
}

void main() {
  try {
    logAndRethrow();
  } catch (e) {
    print('Caught after rethrow: $e');
  }
  // Logging before rethrow: Bad state: deep failure
  // Caught after rethrow: Bad state: deep failure
}

Use rethrow (not throw e) whenever you want to log or react to an error partway up the call stack without swallowing it — throw e technically works but resets the stack trace to this new throw site, making the original failure harder to trace back.

Errors and async/await

An error thrown inside an async function propagates through await exactly like a synchronous exception — a try/catch around the await catches it normally.

Future<void> asyncFailure() async {
  await Future.delayed(const Duration(milliseconds: 10));
  throw Exception('async boom');
}

Future<void> main() async {
  try {
    await asyncFailure();
  } catch (e) {
    print('Caught async error: $e');
    // Caught async error: Exception: async boom
  }
}

The trap: an un-awaited Future's error goes nowhere useful

Future<void> main() async {
  asyncFailure(); // fire-and-forget -- NOT awaited
  print('main continues');
  await Future.delayed(const Duration(milliseconds: 100));
}
main continues
Unhandled exception:
Exception: async boom
#0      asyncFailure (file:...)
<asynchronous suspension>

Because the Future returned by asyncFailure() was never awaited (or given a .catchError()), your surrounding try/catch has nothing to attach to — the error surfaces later as an unhandled exception, often crashing the isolate, with no connection in the stack trace back to where it was called from. This is one of the most common real-world Dart/Flutter bugs: a call that looks handled because it's inside a try block, but isn't, because the await was missing. Rule of thumb: any Future-returning call you don't await needs an explicit .catchError(), or a clear comment explaining why its failure is safe to ignore.

Cheat sheet

Tool Use
implements Exception Mark a custom type as an expected, recoverable failure
on SomeType catch (e) Catch only a specific throwable type
catch (e) (bare) Catch anything, including Errors — put it last
finally Runs regardless of success/failure/rethrow
rethrow Re-throw the current exception, preserving its original stack trace
await inside try/catch Catches errors from the awaited Future normally
Un-awaited Future Its error bypasses any surrounding try/catch

How It Actually Works

Dart's exception model draws a real, load-bearing line between Error and Exception: an Error (like RangeError, TypeError, StateError) signals a programming bug — something that, by definition, shouldn't have been possible if the code were correct — while Exception signals an expected, recoverable failure condition (bad input, a failed network call). The Dart VM treats these somewhat differently in practice: uncaught Errors are the ones you're expected to fix, not catch, which is why blanket catch (e) clauses that swallow everything including programming errors are considered an anti-pattern — they hide bugs that should have crashed loudly during development.

finally is guaranteed to run by the compiler restructuring your function's control-flow graph so that every possible exit path from the try block (normal completion, return, break, continue, or an uncaught exception propagating out) is routed through the finally block before actually leaving. This is a compile-time transformation, not a runtime "best effort" — even a return statement inside a try doesn't actually return until finally has executed, and if finally itself contains a return, it silently overrides whatever the try/catch was about to return.

rethrow differs from throw e at a genuine data level: Dart attaches a StackTrace object to a thrown exception's propagation, and rethrow reuses the original stack trace captured at the original throw site, while throw e inside a catch block creates a brand-new stack trace rooted at the throw statement itself — which is why debugging a rethrown-with-throw exception often shows you the catch block's location instead of where the error actually originated.

Inside async functions, exceptions thrown during an awaited operation are captured by the compiler-generated state machine and delivered into the catch clause of the enclosing try exactly as if execution were synchronous — but an exception thrown by a Future that nobody ever awaits or attaches a .catchError to becomes an unhandled async error, reported to the isolate's uncaught-error handler (or by the Zone running the code) rather than to any try/catch in your function, since there's no active call stack for it to unwind through by the time it fires.

Exercise

Define an exception hierarchy for a simple file-parsing tool: a base ParseException implements Exception with a String reason field, and two subclasses, EmptyFileException and InvalidFormatException, each calling the base constructor with a specific reason. Write a function Map<String, dynamic> parseConfig(String contents) that throws EmptyFileException if contents.trim() is empty, InvalidFormatException if it doesn't contain a : character, and otherwise splits on the first : and returns a one-entry map. Call it with three inputs (empty, malformed, valid) inside a try with on EmptyFileException, on InvalidFormatException, and a final bare catch, confirming each takes the right branch.