← Back to DevBytes

Error Handling Patterns in Dart

Error Handling Patterns in Dart

Robust applications don’t just work when everything goes right — they gracefully handle the unexpected. Dart provides a rich set of language features and conventions for error handling, but knowing the syntax is only half the story. This tutorial dives deep into the patterns that turn raw try-catch blocks into a reliable, maintainable error handling strategy.

What Is Error Handling in Dart?

Dart distinguishes between errors (serious problems that should not be caught under normal circumstances) and exceptions (conditions that can be anticipated and recovered from). The language gives you try, catch, finally, and throw for synchronous code, plus Future.catchError, Stream.handleError, and async/await for asynchronous flows. But a true error handling strategy goes beyond mechanics — it’s about deciding what to catch, how to represent failure, and where to react.

Why It Matters

Understanding Dart’s Error vs Exception

Dart’s core library defines Error and Exception as separate branches. Error typically indicates a programming bug — an AssertionError, TypeError, or NoSuchMethodError — that should not be caught except at the highest level for logging. Exception (and its subclasses like FormatException, IOException) represent recoverable conditions. In practice, most application-level failures should be modeled as custom classes extending Exception or implementing a custom interface, so they can be distinguished in catch clauses.

Synchronous Error Handling: Try-Catch-Finally

The foundation is the classic try-catch-finally block. Always catch the most specific type possible to avoid swallowing unrelated errors.

void parseAndProcess(String input) {
  try {
    final result = complexOperation(input);
    print('Success: $result');
  } on FormatException catch (e, stackTrace) {
    print('Invalid format: $e');
    // log stackTrace
  } on IOException catch (e) {
    print('IO failure: $e');
    // retry or fallback
  } finally {
    // cleanup resources
    cleanup();
  }
}

Use on without catch if you only need to react to the type and not the instance. The finally block runs regardless of whether an exception was thrown, making it ideal for closing files or releasing locks.

Asynchronous Error Handling

Async code brings additional complexity. With await, you can wrap the call in a standard try-catch, but you must ensure the surrounding function is async.

Future<void> fetchUserData() async {
  try {
    final response = await http.get('/user');
    // process response
  } on TimeoutException catch (e) {
    // handle timeout
  } catch (e) {
    // unexpected error
  }
}

For raw Future APIs, use .then().catchError(). The catchError method can filter by type using a test function.

http.get('/user')
  .then((response) => process(response))
  .catchError(
    (error, stackTrace) {
      if (error is TimeoutException) {
        return fallbackData();
      }
      // rethrow or return a value
      throw error;
    },
    test: (error) => error is Exception,
  )
  .whenComplete(() => cleanup());

Streams use handleError or an onError callback in the listen method. Prefer async* generators with try-catch inside the generator body for clarity.

Stream<int> numberStream() async* {
  try {
    for (var i = 0; i < 10; i++) {
      if (i == 5) throw StateError('Unexpected state');
      yield i;
    }
  } catch (e) {
    yield* Stream.error(e);
  }
}

Custom Error Types and Sealed Class Hierarchies

Using a sealed class hierarchy (Dart 3 sealed classes) lets you represent all possible failure modes of an operation in a type-safe way. This pattern works exceptionally well with exhaustive switch and pattern matching.

sealed class DataLoadResult {}
class DataSuccess extends DataLoadResult {
  final List<String> items;
  DataSuccess(this.items);
}
class DataFailure extends DataLoadResult {
  final String message;
  final Exception? cause;
  DataFailure(this.message, {this.cause});
}
class DataEmpty extends DataLoadResult {}

Callers can then use a switch on the sealed type, and the compiler ensures all cases are handled:

void handleResult(DataLoadResult result) {
  switch (result) {
    case DataSuccess(items: final items):
      print('Got ${items.length} items');
    case DataFailure(message: final msg):
      showErrorDialog(msg);
    case DataEmpty():
      showEmptyPlaceholder();
  }
}

This eliminates the “forgotten catch clause” problem and moves error representation into the type system.

The Result Type Pattern (Functional Error Handling)

Inspired by Rust and functional languages, the Result pattern treats errors as values rather than thrown exceptions. Dart’s type system supports a simple implementation using sealed classes, or you can leverage packages like dartz. A minimal Result type:

sealed class Result<T, E extends Exception> {}
class Ok<T, E extends Exception> extends Result<T, E> {
  final T value;
  Ok(this.value);
}
class Err<T, E extends Exception> extends Result<T, E> {
  final E error;
  Err(this.error);
}

Use it to force callers to handle both success and failure paths without relying on try-catch:

Result<String, FormatException> safeParse(String jsonString) {
  try {
    final parsed = json.decode(jsonString);
    return Ok(parsed['name']);
  } on FormatException catch (e) {
    return Err(e);
  }
}

final result = safeParse(malformedJson);
switch (result) {
  case Ok(value: final name):
    print('Hello, $name');
  case Err(error: final e):
    print('Parse error: ${e.message}');
}

This pattern keeps error handling local and explicit, avoiding hidden control flow. It’s especially powerful when chaining operations with map, flatMap, or when methods.

Error Handling in State Management (Riverpod / BLoC)

In state-driven architectures, errors should become part of the state rather than crashing the UI. With Riverpod, a Notifier can expose a sealed state:

sealed class UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState { /* user data */ }
class UserError extends UserState { final String message; UserError(this.message); }

Inside the async logic, catch exceptions and emit the appropriate state:

Future<void> loadUser() async {
  state = UserLoading();
  try {
    final user = await api.fetchUser();
    state = UserLoaded(user);
  } on ApiException catch (e) {
    state = UserError(e.message);
  }
}

The UI listens to the state and renders accordingly. This decouples error appearance from the underlying mechanism and gives full control over retry and recovery actions.

Best Practices Summary

Conclusion

Error handling in Dart is more than a syntax exercise — it’s a design decision that affects code clarity, resilience, and maintainability. By combining try-catch for unexpected runtime exceptions, sealed classes for expected domain failures, and Result types for explicit error-as-value flows, you build applications that fail predictably and recover gracefully. Whether you’re working with raw Futures, Streams, or modern state management, these patterns will keep your error handling clean, type-safe, and ready for production.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles