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
- The Problem with try-catch Everywhere — Using try-catch in every function leads to messy code where error handling logic is scattered everywhere. You never know if a function might throw. Callers forget to wrap calls in try-catch. Exceptions are invisible in function signatures. The Result pattern makes errors explicit and forces callers to handle them.
- The Result Class — Result is a sealed class with two subtypes: Success (holds the data) and Failure (holds error info). Every function that can fail returns a Result. The caller uses fold(), isSuccess, or pattern matching to handle both cases. This is the core error handling pattern in team_mvp_kit.
- Success and Failure Subtypes — Success wraps the actual data of type T. ResultFailure wraps a Failure object that describes what went wrong. These are immutable data classes. Using equatable makes them easy to test and compare.
- Typed Failure Classes — Instead of generic error strings, team_mvp_kit defines specific Failure types for different error categories. NetworkFailure for connection issues, ServerFailure for API errors, AuthFailure for authentication problems, ValidationFailure for form errors. Each carries context about what went wrong.
- Using Result in Repositories — Repositories wrap API calls in try-catch and return Result. Success path returns Result.success with the data. Error path maps DioExceptions to specific Failure types. This keeps all error mapping logic in one place instead of scattered across the app.
- Using Result in BLoCs — BLoCs receive Results from use cases and emit appropriate states. The fold method is perfect here -- on success, emit a loaded state with data; on failure, emit an error state with the failure message. This makes BLoC event handlers clean and consistent.
- Mapping Errors to User Messages — Not every error should show the raw technical message to users. Map failures to user-friendly messages. Network failures become 'Please check your internet connection.' Server errors become 'Something went wrong, please try again.' Auth failures become 'Please sign in again.'
- Result Extensions for Convenience — Add extension methods on Result for common transformations: map to transform the success value, mapFailure to transform the failure, getOrElse to provide a default value, and getOrThrow to unwrap or throw. These make working with Result ergonomic and expressive.
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. Comment: Result sealed class based on team_mvp_kit
- 2. sealed class means only defined subtypes can extend it
- 3. Const constructor for immutability
- 4.
- 5. Factory constructor for creating a Success result
- 6. Factory constructor for creating a Failure result
- 7.
- 8. Boolean check if this Result is a Success
- 9. Boolean check if this Result is a Failure
- 10.
- 11. fold method that handles both cases with callbacks
- 12. Takes an onSuccess function for the success case
- 13. Takes an onFailure function for the failure case
- 14. If this is a Success, call onSuccess with the data
- 15. Otherwise, call onFailure with the failure
- 16. Closing fold and Result class
- 17.
- 18. Success subtype holds the actual data of type T
- 19. The data field contains the success value
- 20. Const constructor for immutability
- 21. Closing Success class
- 22.
- 23. ResultFailure subtype holds the error information
- 24. The failure field contains what went wrong
- 25. Const constructor for immutability
- 26. Closing ResultFailure class
- 27.
- 28. Comment: Failure class with descriptive factory constructors
- 29. Failure class with message and optional status code
- 30. The message describes what went wrong
- 31. Optional HTTP status code for API errors
- 32.
- 33. Constructor with required message and optional statusCode
- 34.
- 35. Factory for network errors (no internet, timeout)
- 36. Factory for server errors with status code
- 37. Factory for authentication errors (401)
- 38. Factory for not found errors (404)
- 39. Closing Failure class
- 40.
- 41. Comment: Repository that returns Result instead of throwing
- 42. Class implements the abstract UserRepository
- 43. Private ApiService field for API calls
- 44. Constructor receives the API service
- 45.
- 46. getUser method returns Result instead of raw User
- 47. Try block wraps the API call
- 48. Fetch user JSON from the API
- 49. Parse JSON into a User object
- 50. Return wrapped in Result.success
- 51. Catch DioException for network and server errors
- 52. Check for connection errors specifically
- 53. Return network failure with descriptive message
- 54. Closing the connection error check
- 55. Return server failure for other Dio errors
- 56. Include the status code from the response
- 57. Closing the DioException catch
- 58. Catch any other unexpected errors
- 59. Return a generic failure message
- 60. Closing the catch-all and getUser method
- 61. Closing UserRepositoryImpl
- 62.
- 63. Comment: BLoC that consumes Result with fold
- 64. UserBloc class declaration
- 65. Private repository field
- 66.
- 67. Constructor sets up event handlers
- 68. Register handler for LoadUser events
- 69. Emit loading state first
- 70.
- 71. Call the repository which returns Result
- 72.
- 73. Use fold to handle both outcomes
- 74. On success: emit UserLoaded with the user data
- 75. On failure: emit UserError with the failure message
- 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
- Sealed Classes in Dart (Dart Official)
- Error Handling in Dart (Dart Official)
- fpdart - Functional Programming in Dart (pub.dev)