Dart Object-Oriented Programming

Error Handling with OOP

50 min Lesson 21 of 24

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
  }
}
Rule of Thumb: Throw 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}');
  }
}
Tip: Order your 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();
  }
}
Warning: Never put 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!
}
Key Benefit: If you later add a new subclass to a sealed class (e.g., 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
}
Tip: The 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
}
Common Mistake: Do not catch 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.
Best Practice: Always include contextual information in your exceptions -- the URL that failed, the query that caused the error, the field that was invalid. This information is invaluable when debugging production issues.