Error Handling, Result Types & Defensive Coding
Production Apps Crash Less When You Handle Errors Right
Open interactive version (quiz + challenge)Real-world analogy
Error handling is like driving with seatbelts and airbags. You hope you never need them, but when something goes wrong (a server is down, a user enters garbage data, the network drops), you need a plan. try/catch is the seatbelt, Result types are the airbag, and defensive coding is driving carefully in the first place.
What is it?
Error handling in Dart uses try/catch/finally for exceptions and Result/Either patterns for typed error handling. Production Flutter apps combine both: try/catch at boundaries (API calls, platform channels), Result types in business logic, and Crashlytics for unhandled errors. Defensive coding validates external data at system boundaries.
Real-world relevance
In a claims processing app, network errors during refund submission must show clear user feedback (not crash). The repository returns Result. The BLoC maps Success to a confirmation screen and Failure to an error state with a retry option. Crashlytics logs the raw error for debugging. The user never sees a red screen.
Key points
- try/catch/finally Basics — try wraps risky code. catch catches exceptions. finally always runs (cleanup). 'on' catches specific types: on FormatException catch (e). Catch the most specific exception first. Bare 'catch' catches everything — avoid it in production.
- Exception vs Error in Dart — Exception: recoverable problems (network timeout, invalid input). Error: programmer bugs (null dereference, stack overflow). NEVER catch Errors — they indicate code bugs that should be fixed. Interview: When should you catch an exception vs let it crash?
- Custom Exception Classes — Create domain exceptions: class AuthException extends AppException {}. class NetworkException extends AppException { final int statusCode; }. This enables specific handling: on AuthException => redirect to login. on NetworkException => show retry.
- Result/Either Pattern — Instead of throwing, return a Result: Result. Success path: Result.success(user). Error path: Result.failure(NetworkFailure()). Callers MUST handle both. No uncaught exceptions. This is the Clean Architecture standard.
- Why Result > Exceptions — Exceptions are invisible in function signatures — Future might throw but the type doesn't show it. Result makes failure VISIBLE in the type. The caller can't ignore it. This prevents the 'I forgot to try/catch' class of bugs.
- Defensive Null Checks — Even with null safety, external data (JSON, API responses) can have unexpected nulls. Validate at the boundary: json['name'] as String? ?? 'Unknown'. Never trust external data. Crash inside your code means YOU have a bug.
- Fail Fast vs Fail Gracefully — Fail fast in development: assert(user != null). Fail gracefully in production: show error UI, log to Crashlytics, offer retry. Never silently swallow errors — that makes debugging impossible.
- Error Boundaries in Flutter — ErrorWidget.builder customizes the red error screen. FlutterError.onError catches framework errors. PlatformDispatcher.instance.onError catches unhandled async errors. Zone.current.handleUncaughtError for zone-level handling.
- Logging and Crash Reporting — Log errors with context: logger.error('Failed to fetch user', error: e, stackTrace: st). Send to Crashlytics/Sentry in production. Include: what failed, what input caused it, and the stack trace. Good logging halves debugging time.
- Retry and Fallback Patterns — Retry with exponential backoff for transient network errors. Fallback to cache when API fails. Circuit breaker: stop retrying after N failures. These production patterns distinguish senior from mid-level code.
Code example
// Error Handling & Result Types — Production Patterns
// --- CUSTOM EXCEPTIONS ---
abstract class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
@override
String toString() => '$runtimeType: $message';
}
class NetworkException extends AppException {
final int? statusCode;
const NetworkException(super.message, {this.statusCode});
}
class AuthException extends AppException {
const AuthException(super.message, {super.code});
}
class CacheException extends AppException {
const CacheException(super.message);
}
// --- TRY/CATCH WITH SPECIFIC TYPES ---
Future<User> fetchUser(String id) async {
try {
final response = await dio.get('/users/$id');
return User.fromJson(response.data);
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
throw const AuthException('Session expired', code: 'TOKEN_EXPIRED');
}
throw NetworkException(
'Failed to fetch user',
statusCode: e.response?.statusCode,
);
} on FormatException catch (e) {
throw AppException('Invalid response format: ${e.message}');
}
// Don't catch generic Exception — let bugs crash in dev
}
// --- RESULT TYPE (Either-style) ---
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
}
class Failure<T> extends Result<T> {
final AppException exception;
final StackTrace? stackTrace;
const Failure(this.exception, {this.stackTrace});
}
// Extension for convenience
extension ResultX<T> on Result<T> {
T getOrElse(T fallback) => switch (this) {
Success(:final data) => data,
Failure() => fallback,
};
Result<R> map<R>(R Function(T) transform) => switch (this) {
Success(:final data) => Success(transform(data)),
Failure(:final exception, :final stackTrace) =>
Failure(exception, stackTrace: stackTrace),
};
}
// --- REPOSITORY USING RESULT ---
class UserRepository {
Future<Result<User>> getUser(String id) async {
try {
final user = await _remoteSource.fetchUser(id);
await _localSource.cacheUser(user); // Cache for offline
return Success(user);
} on NetworkException catch (e, st) {
// Fallback to cache
final cached = await _localSource.getCachedUser(id);
if (cached != null) return Success(cached);
return Failure(e, stackTrace: st);
} on AuthException catch (e, st) {
return Failure(e, stackTrace: st);
}
}
}
// --- BLOC HANDLING RESULT ---
Future<void> _onLoadUser(LoadUser event, Emitter<UserState> emit) async {
emit(const UserLoading());
final result = await _repository.getUser(event.userId);
switch (result) {
case Success(:final data):
emit(UserLoaded(data));
case Failure(:final exception):
emit(UserError(exception.message));
}
}
// --- GLOBAL ERROR HANDLING ---
void main() {
FlutterError.onError = (details) {
FirebaseCrashlytics.instance.recordFlutterError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack);
return true;
};
runApp(const MyApp());
}Line-by-line walkthrough
- 1. Custom exception hierarchy — all app errors extend AppException
- 2. NetworkException adds statusCode for HTTP error context
- 3. try/catch with specific DioException handling
- 4. Checking the status code to differentiate auth errors from network errors
- 5. Throwing a domain-specific exception rather than leaking Dio details
- 6. Sealed Result type — forces callers to handle both success and failure
- 7. Success wraps the actual data
- 8. Failure wraps the exception and optional stack trace
- 9. Extension method for convenient fallback: getOrElse returns data or default
- 10. Repository returning Result instead of throwing
- 11. On network failure, falling back to cached data
- 12. If no cache either, returning Failure
- 13. BLoC handler — pattern matching on Result to emit correct state
- 14. Global error handler sending unhandled errors to Crashlytics
Spot the bug
Future<String> loadConfig() async {
try {
final data = await fetchConfig();
return data;
} catch (e) {
return null;
}
}Need a hint?
What is the return type of this function? What does it return in the catch?
Show answer
Function returns Future<String> (non-nullable) but catch returns null. This is a type error. Fix either: change return type to Future<String?>, throw a custom exception instead of returning null, or return a default value like 'default_config'.
Explain like I'm 5
Imagine you're building a tower of blocks. Sometimes a block falls (an error). try/catch is like having a safety net that catches the block before it hits the floor. A Result type is like having two colored baskets: green for 'it worked!' and red for 'it broke.' When someone hands you a basket, you HAVE to check the color before you open it — you can never accidentally open a red basket thinking it's green.
Fun fact
Google's internal style guide recommends using Result types over exceptions for expected failures. They found that exception-based code has 3x more unhandled error bugs in production than Result-based code, because developers forget to add try/catch but can't forget to handle a Result type — the compiler forces it.
Hands-on challenge
Build an error handling layer for a weather app: create WeatherException hierarchy (NetworkException, LocationException, ParseException), a WeatherRepository that returns Result, and a fallback strategy that tries: 1) API, 2) local cache, 3) last known location. Handle all error paths.
More resources
- Error Handling in Dart (Dart Official)
- Result Type Pattern in Dart (Dart Official)
- Flutter Error Handling (Flutter Official)
- Firebase Crashlytics (Firebase Official)