Error Handling in Dart: Try-Catch, Exceptions, and Debugging Best Practices

Error Handling in Dart

Error Handling in Dart

Every crash report I’ve ever had to triage in a Flutter app came down to the same root cause: an error that wasn’t handled where it should have been. Dart gives me a solid toolkit for dealing with failure — try/catch, custom exceptions, the Error vs Exception distinction, and zone-based global error capture — but it took me a while to understand when to use which tool, and why some errors genuinely shouldn’t be caught at all.

This article covers everything I’ve learned about error handling in Dart, from the basic mechanics up through debugging strategies I use in production Flutter apps.

Table of Contents

  1. Errors vs Exceptions: The Core Distinction
  2. Try-Catch-Finally Fundamentals
  3. Catching Specific Exception Types
  4. The on Clause vs catch Clause
  5. Creating Custom Exceptions
  6. Rethrowing and Error Chaining
  7. Stack Traces and Debugging
  8. Async Error Handling: Futures and async/await
  9. Error Handling in Streams
  10. Internal Mechanics: How Dart Propagates Errors
  11. Global Error Handling in Flutter
  12. Real-World Application: Robust API Layer
  13. Best Practices
  14. Common Mistakes and Debugging Tips
  15. FAQs
  16. Summary and References

1. Errors vs Exceptions: The Core Distinction

Dart’s exception hierarchy has two main branches, both implementing the base Object (there’s no single common Throwable type the way some languages have — anything can technically be thrown, though only Error and Exception subtypes should be).

void main() {
  try {
    List<int> numbers = [1, 2, 3];
    print(numbers[10]); // throws RangeError — a programmer bug
  } catch (e) {
    print('Caught: $e'); // Output: Caught: RangeError (RangeError (index): Invalid value: Not in inclusive range 0..2: 10)
  }
}

I can catch a RangeError, but I generally shouldn’t design my program around recovering from it — the correct fix is to not access an out-of-bounds index in the first place. Contrast that with:

class InsufficientFundsException implements Exception {
  final String message;
  InsufficientFundsException(this.message);
}

void withdraw(double balance, double amount) {
  if (amount > balance) {
    throw InsufficientFundsException('Cannot withdraw \$${amount}, balance is \$${balance}');
  }
}

void main() {
  try {
    withdraw(100, 150);
  } on InsufficientFundsException catch (e) {
    print('Transaction failed: ${e.message}');
    // Output: Transaction failed: Cannot withdraw $150.0, balance is $100.0
  }
}

This is a condition my program is expected to encounter and handle gracefully as part of normal business logic.

2. Try-Catch-Finally Fundamentals

The basic syntax:

void main() {
  try {
    int result = 10 ~/ 0; // integer division by zero throws IntegerDivisionByZeroException
    print(result);
  } catch (e) {
    print('Error occurred: $e');
    // Output: Error occurred: IntegerDivisionByZeroException
  } finally {
    print('Cleanup complete');
    // Output: Cleanup complete
  }
}

finally always runs, whether an exception was thrown or not, and whether it was caught or not — this makes it the right place for cleanup logic like closing a file handle, a database connection, or a stream subscription.

void processFile(bool shouldFail) {
  print('Opening file...');
  try {
    if (shouldFail) {
      throw Exception('File read error');
    }
    print('Processing file...');
  } finally {
    print('Closing file...'); // always runs
  }
}

void main() {
  try {
    processFile(true);
  } catch (e) {
    print('Handled: $e');
  }
}

Output:

Opening file...
Closing file...
Handled: Exception: File read error

Notice the order — finally runs before control returns to the outer catch, even though the exception originated inside the inner try.

3. Catching Specific Exception Types

I rarely write a bare catch (e) in production code, because it catches everything indiscriminately, including bugs I’d rather have surface loudly. Instead, I catch specific types:

void parseAndDivide(String numerator, String denominator) {
  try {
    int a = int.parse(numerator);
    int b = int.parse(denominator);
    print(a ~/ b);
  } on FormatException catch (e) {
    print('Invalid number format: ${e.message}');
  } on IntegerDivisionByZeroException {
    print('Cannot divide by zero');
  } catch (e) {
    print('Unexpected error: $e');
  }
}

void main() {
  parseAndDivide('10', 'abc');  // Output: Invalid number format: ...
  parseAndDivide('10', '0');     // Output: Cannot divide by zero
  parseAndDivide('10', '2');     // Output: 5
}

Multiple on clauses are checked top to bottom, and only the first matching type handles the exception — order matters, especially with class hierarchies (catch subclasses before superclasses).

4. The on Clause vs catch Clause

These are often confused, so here’s the exact distinction:

try {
  throw FormatException('bad input');
} on FormatException {
  print('Matched by type only, no access to the exception object here directly');
}

try {
  throw FormatException('bad input');
} on FormatException catch (e) {
  print('Matched by type, with object: $e');
}

try {
  throw FormatException('bad input');
} catch (e, stackTrace) {
  print('Matched anything, with object and trace: $e');
  print(stackTrace);
}

I use on X catch (e, s) as my default pattern — it gives me both type-safety and access to the details.

5. Creating Custom Exceptions

For any domain-specific failure mode, I define a dedicated exception class rather than throwing a generic Exception('some string'). This lets calling code catch precisely what it needs to handle.

class ValidationException implements Exception {
  final String field;
  final String message;

  ValidationException(this.field, this.message);

  @override
  String toString() => 'ValidationException: $field - $message';
}

class NetworkException implements Exception {
  final int? statusCode;
  final String message;

  NetworkException(this.message, {this.statusCode});

  @override
  String toString() => 'NetworkException($statusCode): $message';
}

void validateAge(int age) {
  if (age < 0 || age > 150) {
    throw ValidationException('age', 'must be between 0 and 150');
  }
}

void main() {
  try {
    validateAge(-5);
  } on ValidationException catch (e) {
    print(e);
    // Output: ValidationException: age - must be between 0 and 150
  }
}

Note I implemented Exception rather than extended it — Exception is a marker interface with no members of its own, so implements Exception (or even just defining a plain class and documenting it as an exception type) is the idiomatic Dart approach. Some developers extend a custom AppException base class instead, which also works well for grouping related exception types under one catchable supertype:

abstract class AppException implements Exception {
  final String message;
  AppException(this.message);
}

class AuthException extends AppException {
  AuthException(super.message);
}

class StorageException extends AppException {
  StorageException(super.message);
}

void main() {
  try {
    throw AuthException('Token expired');
  } on AppException catch (e) {
    print('App-level failure: ${e.message}');
    // Output: App-level failure: Token expired
  }
}

6. Rethrowing and Error Chaining

Sometimes I want to log or annotate an exception without swallowing it — that’s what rethrow is for.

void fetchData() {
  try {
    throw NetworkException('Connection timed out', statusCode: 408);
  } catch (e) {
    print('Logging error before rethrow: $e');
    rethrow; // preserves original stack trace
  }
}

void main() {
  try {
    fetchData();
  } catch (e) {
    print('Final handler: $e');
  }
}

Output:

Logging error before rethrow: NetworkException(408): Connection timed out
Final handler: NetworkException(408): Connection timed out

I always use rethrow, never throw e in this context — throw e resets the stack trace to the current point, losing the original throw location, whereas rethrow preserves it.

For error chaining (wrapping a lower-level exception in a higher-level, more meaningful one), I attach the original as a cause:

class RepositoryException implements Exception {
  final String message;
  final Object cause;
  RepositoryException(this.message, this.cause);

  @override
  String toString() => '$message (caused by: $cause)';
}

void loadUser() {
  try {
    throw FormatException('Unexpected JSON structure');
  } catch (e) {
    throw RepositoryException('Failed to load user profile', e);
  }
}

void main() {
  try {
    loadUser();
  } catch (e) {
    print(e);
    // Output: Failed to load user profile (caused by: FormatException: Unexpected JSON structure)
  }
}

7. Stack Traces and Debugging

Capturing and printing stack traces is essential for diagnosing production issues:

void main() {
  try {
    _levelOne();
  } catch (e, stackTrace) {
    print('Error: $e');
    print('Stack trace:\n$stackTrace');
  }
}

void _levelOne() => _levelTwo();
void _levelTwo() => _levelThree();
void _levelThree() => throw StateError('Something went wrong deep in the call stack');

The printed stack trace shows the full call chain (_levelThree_levelTwo_levelOnemain), which is exactly what I need to trace a bug back to its origin rather than just knowing that something failed.

I also use Error.throwWithStackTrace when I need to rethrow with an explicitly captured trace from a different context (common in async code where the original stack would otherwise be lost):

import 'dart:async';

void main() {
  runZonedGuarded(() {
    throw StateError('Uncaught in zone');
  }, (error, stackTrace) {
    print('Zone caught: $error');
  });
}

8. Async Error Handling: Futures and async/await

Errors in asynchronous code follow the same try/catch syntax when using async/await, which is one of the reasons I prefer async/await over raw .then() chains for readability.

Future<String> fetchUserData(bool shouldFail) async {
  await Future.delayed(const Duration(milliseconds: 200));
  if (shouldFail) {
    throw NetworkException('Server returned 500', statusCode: 500);
  }
  return 'User data loaded';
}

Future<void> main() async {
  try {
    final data = await fetchUserData(true);
    print(data);
  } on NetworkException catch (e) {
    print('Network failure: $e');
    // Output: Network failure: NetworkException(500): Server returned 500
  }
}

With raw Future chaining (no async/await), the equivalent is:

void main() {
  fetchUserData(true)
      .then((data) => print(data))
      .catchError((e) {
        print('Network failure: $e');
      });
}

catchError accepts an optional test parameter to filter by error type, similar to on:

fetchUserData(true).catchError(
  (e) => print('Handled network error: $e'),
  test: (e) => e is NetworkException,
);

I default to async/await with try/catch in almost all new code — it reads top-to-bottom like synchronous code and avoids the callback-chain complexity of nested .then()/.catchError().

Unhandled Future Errors

If a Future throws and nothing catches it, Dart reports it as an unhandled exception — in Flutter this typically surfaces via the red error screen in debug mode, or gets silently logged (and potentially crashes the isolate) in release mode if not caught by a zone handler.

9. Error Handling in Streams

Streams have their own error-handling channel, separate from the data channel, delivered via the onError callback:

Stream<int> numberStream() async* {
  yield 1;
  yield 2;
  throw Exception('Stream error at position 3');
}

void main() {
  numberStream().listen(
    (data) => print('Data: $data'),
    onError: (e) => print('Stream error: $e'),
    onDone: () => print('Stream complete'),
  );
}

Output:

Data: 1
Data: 2
Stream error: Exception: Stream error at position 3
Stream complete

Note that the stream still calls onDone after an error unless cancelOnError: true is set:

numberStream().listen(
  (data) => print('Data: $data'),
  onError: (e) => print('Stream error: $e'),
  onDone: () => print('Stream complete'),
  cancelOnError: true, // stream subscription stops after first error
);

When consuming a stream with await for, I wrap it in a standard try/catch:

Future<void> consumeStream() async {
  try {
    await for (final value in numberStream()) {
      print('Value: $value');
    }
  } catch (e) {
    print('Caught in await-for: $e');
  }
}

10. Internal Mechanics: How Dart Propagates Errors

Understanding why certain error-handling patterns behave the way they do requires a quick look at Dart’s execution model.

Synchronous errors propagate up the call stack immediately, unwinding each frame until a matching catch (or on clause) is found, exactly like most C-family languages.

Asynchronous errors are different: a Future that throws doesn’t unwind a live call stack — it instead completes with an error state, which is only “thrown” again when something awaits it or attaches a .catchError()/.then(onError:) handler. This is why an uncaught error inside an async function doesn’t crash the whole isolate immediately; it lives inside the returned Future until something inspects it.

Zones are Dart’s mechanism for intercepting errors across asynchronous boundaries that wouldn’t otherwise be catchable by a simple try/catch — for instance, an error thrown inside a Timer callback or a detached microtask. runZonedGuarded establishes a zone where any otherwise-uncaught error (sync or async) within that zone gets routed to a custom handler:

import 'dart:async';

void main() {
  runZonedGuarded(() {
    Timer(const Duration milliseconds: 100), () {
      throw StateError('Error inside a Timer callback');
    });
  }, (error, stack) {
    print('Caught by zone: $error');
  });
}

This is exactly the mechanism Flutter itself uses internally to catch framework-level and app-level errors that occur outside the widget build/error-boundary system.

11. Global Error Handling in Flutter

In a Flutter app, I set up two complementary layers of global error capture in main():

import 'package:flutter/material.dart';

void main() {
  FlutterError.onError = (FlutterErrorDetails details) {
    // Catches errors during widget building, layout, painting
    FlutterError.presentError(details);
    // Send to your crash reporting service here
  };

  runZonedGuarded(() {
    runApp(const MyApp());
  }, (error, stackTrace) {
    // Catches errors outside the Flutter framework's own error zone
    // (e.g. inside async gaps, timers, isolates)
    print('Uncaught zone error: $error');
    // Send to your crash reporting service here
  });
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Scaffold(body: Center(child: Text('App running'))));
  }
}

FlutterError.onError catches errors within the Flutter framework’s rendering pipeline (a widget’s build method throwing, for instance). runZonedGuarded catches everything else that would otherwise crash the isolate silently. I always wire up both in production apps, typically alongside a crash reporting SDK.

12. Real-World Application: Robust API Layer

Putting this all together, here’s the pattern I use for a typed, resilient API client:

class ApiException implements Exception {
  final int statusCode;
  final String message;
  ApiException(this.statusCode, this.message);

  @override
  String toString() => 'ApiException($statusCode): $message';
}

class ApiClient {
  Future<Map<String, dynamic>> getUser(String id) async {
    try {
      // Simulated network call
      await Future.delayed(const Duration(milliseconds: 300));
      if (id.isEmpty) {
        throw ApiException(400, 'User ID cannot be empty');
      }
      return {'id': id, 'name': 'Ayesha Khan'};
    } on ApiException {
      rethrow; // preserve known exception type for callers
    } catch (e, stackTrace) {
      // Wrap unexpected errors with context, don't leak raw internals
      throw ApiException(500, 'Unexpected error while fetching user: $e');
    }
  }
}

Future<void> main() async {
  final client = ApiClient();
  try {
    final user = await client.getUser('');
    print(user);
  } on ApiException catch (e) {
    print('Request failed: $e');
    // Output: Request failed: ApiException(400): User ID cannot be empty
  }
}

This layer guarantees that anything calling ApiClient only ever has to handle one exception type (ApiException) at the call site, regardless of what actually failed underneath.

13. Best Practices

14. Common Mistakes and Debugging Tips

Mistake 1 — Catching and discarding:

try {
  riskyOperation();
} catch (e) {} // Silent failure — avoid this

At minimum, log the error even if you can’t act on it further.

Mistake 2 — Using throw e instead of rethrow: This resets the stack trace, making the original failure point invisible in your logs.

Mistake 3 — Catching Error types as if they were recoverable Exceptions: If you’re regularly catching TypeError or RangeError in production, that’s a signal of a bug to fix, not a case to handle gracefully forever.

Mistake 4 — Forgetting that async errors need to be awaited or have .catchError() attached:

void main() {
  fetchUserData(true); // Fire-and-forget — the error is never observed!
}

This can result in an “Unhandled exception” warning with no clear origin. Always await it inside a try/catch, or explicitly attach .catchError().

Debugging tip: In Flutter, use FlutterError.dumpErrorToConsole or a proper crash reporting tool (like Sentry or Firebase Crashlytics) integrated via runZonedGuarded — relying only on the debug console misses errors that occur in release builds on real user devices.

15. FAQs

Q: Should I use Exception or Error for my custom exceptions? Use Exception (or implement it) for anything your application logic is expected to encounter and recover from. Reserve Error subclasses (or let Dart’s built-in ones surface) for actual programming mistakes.

Q: Does finally run if the try block returns a value? Yes — finally always executes before the function actually returns, even if return appears inside try or catch.

Q: Can I catch multiple exception types with one clause? Not directly with a single on clause, but you can stack multiple on clauses on one try, or catch a common supertype if your exceptions share one.

Q: Why doesn’t my try/catch catch an error thrown inside a Future.delayed callback that isn’t awaited? Because that error occurs asynchronously, outside the synchronous stack your try block is watching. You need to either await the future inside the try, or use runZonedGuarded to catch it globally.

Q: What’s the difference between catchError on a Future and onError on a Stream? They’re conceptually similar — both intercept errors on their respective asynchronous primitive — but catchError on a Future completes that single future’s error channel, while a Stream‘s onError can fire multiple times over the stream’s lifetime, once per error event.

16. Summary

Solid error handling in Dart comes down to a few consistent habits: distinguish Error (bugs) from Exception (expected failures), catch specific types rather than everything indiscriminately, preserve stack traces with rethrow, and understand that asynchronous errors travel through Futures and Streams rather than a live call stack — which is exactly why zones exist as the global safety net. Applying these consistently, from a single try/catch block up to a Flutter app’s global runZonedGuarded handler, is what separates an app that fails loudly and informatively in development from one that crashes mysteriously in production.

References

Exit mobile version