Error Handling with OOP
Why OOP Error Handling Matters
In any real application, things go wrong: network requests fail, user input is invalid, files are missing, and business rules are violated. Dart’s built-in Exception and Error classes provide a foundation, but professional code requires custom exception hierarchies that make error handling precise, readable, and maintainable. By combining OOP principles with error handling, you create systems where errors are typed, catchable at the right level, and informative.
In this lesson, you’ll learn to build exception class hierarchies, use the Result pattern for functional error handling, and apply sealed classes for exhaustive error types -- techniques used in production Flutter and Dart applications.
Dart’s Error vs Exception
Before building custom types, understand the distinction Dart makes between Error and Exception:
Error-- Represents a programming mistake (bug). Examples:TypeError,RangeError,StateError. These should not be caught in normal code; they indicate the program is broken.Exception-- Represents a recoverable condition. Examples:FormatException,IOException,HttpException. These should be caught and handled gracefully.
Error vs Exception
void main() {
// Error -- programming mistake, should NOT be caught normally
List<int> numbers = [1, 2, 3];
// numbers[10]; // RangeError -- this is a bug in your code
// Exception -- recoverable situation, SHOULD be caught
try {
int result = int.parse('not_a_number');
} on FormatException catch (e) {
print('Invalid format: $e'); // Handle gracefully
}
}
Exception subclasses for things the caller might want to recover from. Reserve Error subclasses for programming mistakes that should be fixed in the code.Creating Custom Exception Classes
Custom exceptions let you carry specific error data and create meaningful error types for your application domain. Always implement the Exception interface (not Error).
Basic Custom Exception
class ValidationException implements Exception {
final String field;
final String message;
const ValidationException({
required this.field,
required this.message,
});
@override
String toString() => 'ValidationException: $field -- $message';
}
class NotFoundException implements Exception {
final String entityType;
final String id;
const NotFoundException({
required this.entityType,
required this.id,
});
@override
String toString() => '$entityType with id "$id" not found';
}
void main() {
try {
throw ValidationException(
field: 'email',
message: 'Invalid email format',
);
} on ValidationException catch (e) {
print(e); // ValidationException: email -- Invalid email format
print(e.field); // email
print(e.message); // Invalid email format
}
}
Building Exception Hierarchies
Real applications need families of related exceptions. An exception hierarchy lets you catch errors at different levels of specificity -- catch a broad category or a specific subtype.
Exception Hierarchy for an API Client
// Base exception for all API-related errors
abstract class ApiException implements Exception {
final String message;
final int? statusCode;
final String? requestUrl;
const ApiException({
required this.message,
this.statusCode,
this.requestUrl,
});
@override
String toString() => 'ApiException($statusCode): $message';
}
// Network-level errors
class NetworkException extends ApiException {
final Duration? timeout;
const NetworkException({
required super.message,
super.requestUrl,
this.timeout,
}) : super(statusCode: null);
}
// Server returned an error response
class ServerException extends ApiException {
final String? responseBody;
const ServerException({
required super.message,
required int super.statusCode,
super.requestUrl,
this.responseBody,
});
}
// Authentication/authorization errors
class AuthException extends ApiException {
final bool tokenExpired;
const AuthException({
required super.message,
super.statusCode,
super.requestUrl,
this.tokenExpired = false,
});
}
// Rate limiting
class RateLimitException extends ApiException {
final Duration retryAfter;
const RateLimitException({
required this.retryAfter,
super.requestUrl,
}) : super(message: 'Rate limit exceeded', statusCode: 429);
}
// Usage -- catch at different levels
void handleApiCall() {
try {
// ... make API call
throw ServerException(
message: 'Internal Server Error',
statusCode: 500,
requestUrl: '/api/users',
responseBody: '{"error": "database unavailable"}',
);
} on AuthException catch (e) {
// Handle auth errors specifically
if (e.tokenExpired) {
print('Token expired, refreshing...');
} else {
print('Not authorized: ${e.message}');
}
} on RateLimitException catch (e) {
// Handle rate limiting
print('Rate limited. Retry after ${e.retryAfter.inSeconds}s');
} on ServerException catch (e) {
// Handle server errors
print('Server error ${e.statusCode}: ${e.message}');
} on NetworkException catch (e) {
// Handle network errors
print('Network error: ${e.message}');
} on ApiException catch (e) {
// Catch-all for any API exception
print('API error: ${e.message}');
}
}
catch blocks from most specific to most general. Dart matches the first matching type, so putting ApiException first would catch everything and the specific handlers would never run.Try-Catch-Finally with OOP
The finally block runs regardless of whether an exception was thrown. This is essential for cleanup operations like closing connections, releasing resources, or resetting state.
Resource Management with Try-Catch-Finally
class DatabaseConnection {
final String connectionString;
bool _isOpen = false;
DatabaseConnection(this.connectionString);
void open() {
print('Opening connection to $connectionString');
_isOpen = true;
}
void close() {
if (_isOpen) {
print('Closing connection to $connectionString');
_isOpen = false;
}
}
List<Map<String, dynamic>> query(String sql) {
if (!_isOpen) {
throw StateError('Connection is not open');
}
// Simulate a query that might fail
if (sql.contains('invalid_table')) {
throw DatabaseException(
message: 'Table "invalid_table" does not exist',
query: sql,
);
}
return [
{'id': 1, 'name': 'Alice'},
];
}
}
class DatabaseException implements Exception {
final String message;
final String? query;
const DatabaseException({required this.message, this.query});
@override
String toString() => 'DatabaseException: $message';
}
void fetchUsers() {
final db = DatabaseConnection('localhost:5432/mydb');
try {
db.open();
final results = db.query('SELECT * FROM users');
print('Found ${results.length} users');
} on DatabaseException catch (e) {
print('Database error: ${e.message}');
if (e.query != null) {
print('Failed query: ${e.query}');
}
} on StateError catch (e) {
print('State error: $e');
} finally {
// ALWAYS runs -- ensures connection is closed
db.close();
}
}
return statements inside a finally block. While Dart allows it, a return in finally will override any exception that was being thrown, silently swallowing the error.Sealed Classes for Error Types
Dart 3 introduced sealed classes, which are perfect for modeling a closed set of error types. The compiler can verify that you handle every possible error type in a switch expression -- no missing cases.
Sealed Error Types
sealed class AppError {
final String message;
const AppError(this.message);
}
class NetworkError extends AppError {
final int? statusCode;
const NetworkError(super.message, {this.statusCode});
}
class ValidationError extends AppError {
final Map<String, String> fieldErrors;
const ValidationError(super.message, {required this.fieldErrors});
}
class StorageError extends AppError {
final String path;
const StorageError(super.message, {required this.path});
}
class AuthenticationError extends AppError {
final bool sessionExpired;
const AuthenticationError(super.message, {this.sessionExpired = false});
}
// The compiler ensures ALL subtypes are handled
String handleError(AppError error) {
return switch (error) {
NetworkError(statusCode: var code) =>
'Network issue${code != null ? " (HTTP $code)" : ""}',
ValidationError(fieldErrors: var errors) =>
'Invalid input: ${errors.entries.map((e) => "${e.key}: ${e.value}").join(", ")}',
StorageError(path: var p) =>
'Storage error at $p: ${error.message}',
AuthenticationError(sessionExpired: true) =>
'Your session has expired. Please log in again.',
AuthenticationError() =>
'Authentication failed: ${error.message}',
};
// No default needed -- compiler knows all cases are covered!
}
PermissionError), the compiler will show errors at every switch statement that doesn’t handle the new type. This makes refactoring safe.The Result Pattern
Instead of throwing exceptions, you can return a Result type that explicitly represents either success or failure. This makes error handling part of the function’s signature -- callers cannot forget to handle errors because the return type forces them to check.
Implementing the Result Pattern
// A generic Result type using sealed classes
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final AppError error;
const Failure(this.error);
}
// Extension methods for convenience
extension ResultExtensions<T> on Result<T> {
bool get isSuccess => this is Success<T>;
bool get isFailure => this is Failure<T>;
T? get valueOrNull => switch (this) {
Success(value: var v) => v,
Failure() => null,
};
Result<R> map<R>(R Function(T value) transform) => switch (this) {
Success(value: var v) => Success(transform(v)),
Failure(error: var e) => Failure(e),
};
T getOrElse(T Function(AppError error) orElse) => switch (this) {
Success(value: var v) => v,
Failure(error: var e) => orElse(e),
};
}
// Usage in a service
class UserService {
final Map<String, Map<String, String>> _users = {
'1': {'name': 'Alice', 'email': 'alice@example.com'},
'2': {'name': 'Bob', 'email': 'bob@example.com'},
};
Result<Map<String, String>> getUserById(String id) {
final user = _users[id];
if (user == null) {
return Failure(NetworkError('User not found', statusCode: 404));
}
return Success(user);
}
Result<String> getUserEmail(String id) {
return getUserById(id).map((user) => user['email']!);
}
}
void main() {
final service = UserService();
// Must handle both cases -- cannot ignore errors
final result = service.getUserById('1');
switch (result) {
case Success(value: var user):
print('Found: ${user["name"]}');
case Failure(error: var error):
print('Error: ${error.message}');
}
// Or use convenience methods
final email = service.getUserEmail('99').getOrElse(
(error) => 'unknown@example.com',
);
print('Email: $email'); // Email: unknown@example.com
}
Result pattern is especially useful in Flutter where you want to show different UI states (loading, success, error) based on the result of an async operation. Combined with sealed classes, the compiler ensures you handle every state.Practical Example: Complete API Error Handling
Let’s build a complete, production-quality error handling system for a REST API client. This combines exception hierarchies, sealed classes, and the Result pattern.
Production API Error Handling
// ---- Error Types (Sealed) ----
sealed class ApiError {
final String message;
final String? requestUrl;
final DateTime timestamp;
ApiError(this.message, {this.requestUrl})
: timestamp = DateTime.now();
}
class ConnectionError extends ApiError {
final String reason;
ConnectionError({required this.reason, String? requestUrl})
: super('Connection failed: $reason', requestUrl: requestUrl);
}
class HttpError extends ApiError {
final int statusCode;
final Map<String, dynamic>? body;
HttpError({
required this.statusCode,
required String message,
this.body,
String? requestUrl,
}) : super(message, requestUrl: requestUrl);
bool get isClientError => statusCode >= 400 && statusCode < 500;
bool get isServerError => statusCode >= 500;
}
class ParseError extends ApiError {
final String rawResponse;
ParseError({required this.rawResponse, String? requestUrl})
: super('Failed to parse response', requestUrl: requestUrl);
}
class TimeoutError extends ApiError {
final Duration duration;
TimeoutError({required this.duration, String? requestUrl})
: super('Request timed out after ${duration.inSeconds}s',
requestUrl: requestUrl);
}
// ---- Result Type ----
sealed class ApiResult<T> {
const ApiResult();
}
class ApiSuccess<T> extends ApiResult<T> {
final T data;
final int statusCode;
const ApiSuccess(this.data, {this.statusCode = 200});
}
class ApiFailure<T> extends ApiResult<T> {
final ApiError error;
const ApiFailure(this.error);
}
// ---- API Client ----
class ApiClient {
final String baseUrl;
ApiClient(this.baseUrl);
Future<ApiResult<Map<String, dynamic>>> get(String path) async {
final url = '$baseUrl$path';
try {
// Simulate network call
await Future.delayed(Duration(milliseconds: 100));
// Simulate different responses
if (path.contains('timeout')) {
return ApiFailure(TimeoutError(
duration: Duration(seconds: 30),
requestUrl: url,
));
}
if (path.contains('not-found')) {
return ApiFailure(HttpError(
statusCode: 404,
message: 'Resource not found',
requestUrl: url,
));
}
return ApiSuccess({'id': 1, 'name': 'Test User'});
} catch (e) {
return ApiFailure(ConnectionError(
reason: e.toString(),
requestUrl: url,
));
}
}
}
// ---- Using the System ----
Future<void> main() async {
final client = ApiClient('https://api.example.com');
final result = await client.get('/users/1');
final output = switch (result) {
ApiSuccess(data: var user, statusCode: var code) =>
'[$code] User: ${user["name"]}',
ApiFailure(error: ConnectionError(reason: var r)) =>
'No connection: $r',
ApiFailure(error: HttpError(statusCode: var code, :var message)) =>
'HTTP $code: $message',
ApiFailure(error: ParseError(rawResponse: var raw)) =>
'Bad response: ${raw.substring(0, 50)}...',
ApiFailure(error: TimeoutError(duration: var d)) =>
'Timed out after ${d.inSeconds}s',
};
print(output); // [200] User: Test User
}
Error types (like StackOverflowError or OutOfMemoryError) in your API error handling. These represent critical failures that your application cannot meaningfully recover from. Only catch Exception types and your own error classes.