Lesson 8 of 77 intermediate

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

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. 1. Custom exception hierarchy — all app errors extend AppException
  2. 2. NetworkException adds statusCode for HTTP error context
  3. 3. try/catch with specific DioException handling
  4. 4. Checking the status code to differentiate auth errors from network errors
  5. 5. Throwing a domain-specific exception rather than leaking Dio details
  6. 6. Sealed Result type — forces callers to handle both success and failure
  7. 7. Success wraps the actual data
  8. 8. Failure wraps the exception and optional stack trace
  9. 9. Extension method for convenient fallback: getOrElse returns data or default
  10. 10. Repository returning Result instead of throwing
  11. 11. On network failure, falling back to cached data
  12. 12. If no cache either, returning Failure
  13. 13. BLoC handler — pattern matching on Result to emit correct state
  14. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery