Lesson 49 of 51 advanced

Error Handling & Result Pattern

Failing Gracefully Like a Pro

Open interactive version (quiz + challenge)

Real-world analogy

Imagine you are a pilot. You do not just hope nothing goes wrong -- you have checklists for every possible failure. Engine out? Checklist. Radio fails? Checklist. The Result pattern is your app's checklist system. Instead of crashing when something unexpected happens, your code says 'okay, this failed, here is exactly what went wrong and here is plan B.' Every function returns either a Success (smooth landing) or a Failure (specific error with recovery instructions).

What is it?

The Result pattern replaces scattered try-catch blocks with explicit return types. Every function that can fail returns Result which is either Success(data) or Failure(error). This makes errors visible in function signatures, forces callers to handle both cases, and centralizes error mapping. team_mvp_kit uses this pattern in all repositories and use cases.

Real-world relevance

In team_mvp_kit, every repository method returns Result. The repository maps DioExceptions to typed Failures (network, server, auth, not found). Use cases pass Results to BLoCs. BLoCs use fold() to emit loaded or error states. Error messages are mapped to user-friendly text before displaying. This chain ensures no error is silently swallowed.

Key points

Code example

// 1. Result sealed class (team_mvp_kit pattern)
sealed class Result<T> {
  const Result();

  factory Result.success(T data) = Success<T>;
  factory Result.failure(Failure failure) = ResultFailure<T>;

  bool get isSuccess => this is Success<T>;
  bool get isFailure => this is ResultFailure<T>;

  R fold<R>({
    required R Function(T data) onSuccess,
    required R Function(Failure failure) onFailure,
  }) {
    if (this is Success<T>) {
      return onSuccess((this as Success<T>).data);
    }
    return onFailure((this as ResultFailure<T>).failure);
  }
}

class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);
}

class ResultFailure<T> extends Result<T> {
  final Failure failure;
  const ResultFailure(this.failure);
}

// 2. Failure class with factory constructors
class Failure {
  final String message;
  final int? statusCode;

  const Failure({required this.message, this.statusCode});

  factory Failure.network(String msg) =>
      Failure(message: msg);
  factory Failure.server(String msg, int code) =>
      Failure(message: msg, statusCode: code);
  factory Failure.auth(String msg) =>
      Failure(message: msg, statusCode: 401);
  factory Failure.notFound(String resource) =>
      Failure(message: '$resource not found', statusCode: 404);
}

// 3. Repository using Result
class UserRepositoryImpl implements UserRepository {
  final ApiService _api;
  UserRepositoryImpl(this._api);

  Future<Result<User>> getUser(int id) async {
    try {
      final json = await _api.get('/users/$id');
      final user = User.fromJson(json);
      return Result.success(user);
    } on DioException catch (e) {
      if (e.type == DioExceptionType.connectionError) {
        return Result.failure(
          Failure.network('No internet connection'),
        );
      }
      return Result.failure(
        Failure.server(
          'Server error',
          e.response?.statusCode ?? 500,
        ),
      );
    } catch (e) {
      return Result.failure(
        Failure(message: 'Unexpected error'),
      );
    }
  }
}

// 4. BLoC consuming Result with fold
class UserBloc extends Bloc<UserEvent, UserState> {
  final UserRepository _repo;

  UserBloc(this._repo) : super(UserInitial()) {
    on<LoadUser>((event, emit) async {
      emit(UserLoading());

      final result = await _repo.getUser(event.userId);

      result.fold(
        onSuccess: (user) => emit(UserLoaded(user)),
        onFailure: (f) => emit(UserError(f.message)),
      );
    });
  }
}

Line-by-line walkthrough

  1. 1. Comment: Result sealed class based on team_mvp_kit
  2. 2. sealed class means only defined subtypes can extend it
  3. 3. Const constructor for immutability
  4. 4.
  5. 5. Factory constructor for creating a Success result
  6. 6. Factory constructor for creating a Failure result
  7. 7.
  8. 8. Boolean check if this Result is a Success
  9. 9. Boolean check if this Result is a Failure
  10. 10.
  11. 11. fold method that handles both cases with callbacks
  12. 12. Takes an onSuccess function for the success case
  13. 13. Takes an onFailure function for the failure case
  14. 14. If this is a Success, call onSuccess with the data
  15. 15. Otherwise, call onFailure with the failure
  16. 16. Closing fold and Result class
  17. 17.
  18. 18. Success subtype holds the actual data of type T
  19. 19. The data field contains the success value
  20. 20. Const constructor for immutability
  21. 21. Closing Success class
  22. 22.
  23. 23. ResultFailure subtype holds the error information
  24. 24. The failure field contains what went wrong
  25. 25. Const constructor for immutability
  26. 26. Closing ResultFailure class
  27. 27.
  28. 28. Comment: Failure class with descriptive factory constructors
  29. 29. Failure class with message and optional status code
  30. 30. The message describes what went wrong
  31. 31. Optional HTTP status code for API errors
  32. 32.
  33. 33. Constructor with required message and optional statusCode
  34. 34.
  35. 35. Factory for network errors (no internet, timeout)
  36. 36. Factory for server errors with status code
  37. 37. Factory for authentication errors (401)
  38. 38. Factory for not found errors (404)
  39. 39. Closing Failure class
  40. 40.
  41. 41. Comment: Repository that returns Result instead of throwing
  42. 42. Class implements the abstract UserRepository
  43. 43. Private ApiService field for API calls
  44. 44. Constructor receives the API service
  45. 45.
  46. 46. getUser method returns Result instead of raw User
  47. 47. Try block wraps the API call
  48. 48. Fetch user JSON from the API
  49. 49. Parse JSON into a User object
  50. 50. Return wrapped in Result.success
  51. 51. Catch DioException for network and server errors
  52. 52. Check for connection errors specifically
  53. 53. Return network failure with descriptive message
  54. 54. Closing the connection error check
  55. 55. Return server failure for other Dio errors
  56. 56. Include the status code from the response
  57. 57. Closing the DioException catch
  58. 58. Catch any other unexpected errors
  59. 59. Return a generic failure message
  60. 60. Closing the catch-all and getUser method
  61. 61. Closing UserRepositoryImpl
  62. 62.
  63. 63. Comment: BLoC that consumes Result with fold
  64. 64. UserBloc class declaration
  65. 65. Private repository field
  66. 66.
  67. 67. Constructor sets up event handlers
  68. 68. Register handler for LoadUser events
  69. 69. Emit loading state first
  70. 70.
  71. 71. Call the repository which returns Result
  72. 72.
  73. 73. Use fold to handle both outcomes
  74. 74. On success: emit UserLoaded with the user data
  75. 75. On failure: emit UserError with the failure message
  76. 76. Closing fold, event handler, and UserBloc

Spot the bug

Future<Result<User>> getUser(int id) async {
  try {
    final json = await _api.get('/users/$id');
    final user = User.fromJson(json);
    return Result.success(user);
  } on DioException catch (e) {
    return Result.failure(Failure.network(e.message ?? ''));
  }
}

// In BLoC:
final result = await _repo.getUser(42);
emit(UserLoaded(result.data));
Need a hint?
Look at how the BLoC uses the result. Is it handling the failure case?
Show answer
The BLoC accesses result.data directly without checking if the result is a success or failure. If the result is a Failure, result.data will be null and UserLoaded will receive null. Fix: use result.fold(onSuccess: (user) => emit(UserLoaded(user)), onFailure: (f) => emit(UserError(f.message))). Also, the repository only catches DioException but not other errors (e.g., JSON parsing errors). Add a generic catch(e) block.

Explain like I'm 5

Imagine every time you ask your friend a question, they either give you an answer (success) or say 'I do not know because...' with a reason (failure). You always get ONE of those two responses, never silence or a surprise. That is the Result pattern. Instead of your app suddenly crashing with a mysterious error, every operation politely says either 'Here is your data!' or 'Sorry, this went wrong because X.' And you always check which one you got before doing anything with it!

Fun fact

The Result pattern (also called Either in functional programming) was popularized by languages like Rust (Result), Kotlin (Result), and Swift (Result). Dart does not have a built-in Result type, but the community has embraced it so much that there are multiple packages for it, including dartz and fpdart. team_mvp_kit implements its own simple version to avoid external dependencies!

Hands-on challenge

Implement a complete Result sealed class with Success and ResultFailure subtypes. Create a Failure class with network, server, auth, and notFound factories. Then refactor a simple repository method to return Result> instead of throwing exceptions. Use fold() in a BLoC to emit success or error states.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart