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
- User experience: Uncaught errors crash your app or leave UI in broken states. Good handling shows meaningful feedback.
- Debugging & logging: Structured error handling gives you stack traces and context, making it easier to diagnose issues in production.
- Separation of concerns: Business logic shouldn’t dictate UI behavior; error handling patterns bridge that gap cleanly.
- Asynchronous safety: Futures and Streams propagate failures differently; missing a catchError can lead to unhandled exceptions that tear down isolates.
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
- Catch specific exceptions: Avoid
catch (e)without a type qualifier unless at the very top level. It masks bugs. - Don’t catch plain
Error: Let programming errors propagate to a global handler that logs them — catching them can hide logic bugs. - Use sealed classes for domain failures: Model expected failure outcomes as part of your return type, not as raw exceptions.
- Always rethrow or wrap: When you catch an exception but cannot fully handle it, rethrow or wrap it in a domain-specific exception to preserve context.
- Log with stack traces: In catch clauses, always capture and log the stack trace (
catch (e, st)). It’s invaluable for debugging. - Handle async errors at the source: Attach
catchErrororonErroras close to the Future/Stream creation as possible to avoid unhandled exceptions. - Test failure paths: Write unit tests that inject exceptions and verify your error handling logic just as thoroughly as the happy path.
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.