Catch And Flow

A Flutter package for streamlined error handling, logging, and asynchronous flow control. Simplify your error management with type-safe custom errors and structured logging.

Features

  • Unified error handling for Futures and Streams
  • Type-safe custom error hierarchy
  • Structured logging with configurable log levels
  • Simple API for handling async operations
  • Extension methods for cleaner error handling
  • Result type for functional error handling

Getting started

Add the package to your pubspec.yaml:

dependencies:
  catch_and_flow: ^1.0.0

Configure a logger before using the package:

import 'package:catch_and_flow/catch_and_flow.dart';

class MyCustomLogger implements CatchAndFlowLogger {
  @override
  void logDebug(dynamic message) => print('DEBUG: $message');
  
  @override
  void logError(dynamic message, [dynamic error, StackTrace? stackTrace]) {
    print('ERROR: $message');
    if (error != null) print('   $error');
    if (stackTrace != null) print('   $stackTrace');
  }
  
  @override
  void logInfo(dynamic message) => print('INFO: $message');
  
  @override
  void logWarning(dynamic message) => print('WARN: $message');
}

void main() {
  // Set up the logger
  CatchAndFlow.setLogger(MyCustomLogger());
  // Set the log level (optional, defaults to LogLevel.error)
  CatchAndFlow.setLogLevel(LogLevel.debug);
  
  runApp(MyApp());
}

Usage

Working with Futures

Basic future error handling

import 'package:catch_and_flow/catch_and_flow.dart';

Future<User> fetchUser() async {
  return runSafetyFuture(() async {
    final response = await http.get(Uri.parse('https://api.example.com/user'));
    if (response.statusCode == 200) {
      return User.fromJson(jsonDecode(response.body));
    } else {
      throw GenericError(
        code: 'api-error',
        message: 'Failed to fetch user: ${response.statusCode}'
      );
    }
  });
}

Using the Future extension with when

void loadUser() {
  fetchUser().when(
    // Called immediately when the operation starts
    progress: () => showLoadingIndicator(),
    // Called when the Future completes successfully
    success: (user) => showUserProfile(user),
    // Called when the Future completes with an error
    error: (error) => showErrorMessage(error.message),
    // Optional: Override the log level for this specific operation
    logLevel: LogLevel.debug,
  );
}

Future with custom error transformation

Future<User> fetchUserWithCustomErrors() {
  return runSafetyFuture(
    () async => /* API call that might throw */,
    onError: (e) {
      if (e is SocketException) {
        return NetworkError(
          statusCode: 0, 
          message: 'No internet connection'
        );
      } else if (e is HttpException && e.toString().contains('401')) {
        return AuthenticationError(message: 'Unauthorized');
      }
      return GenericError(message: e.toString());
    },
    logLevel: LogLevel.warning, // Optional: custom log level
  );
}

Future with null on error

// Returns the user or null if an error occurs
Future<User?> fetchUserSafe() async {
  return runSafetyFutureNullable(() async {
    return await api.getUser();
  }, logLevel: LogLevel.info); // Optional: custom log level
}

Working with Streams

Basic stream error handling

Stream<String> getStatusUpdates() {
  return runSafetyStream(() => 
    _api.statusUpdates.map((response) {
      if (response.isValid) {
        return response.message;
      } else {
        throw GenericError(
          code: 'invalid-status',
          message: 'Received invalid status update'
        );
      }
    })
  );
}

Using the Stream extension with when

StreamSubscription<String> subscribeToUpdates() {
  return getStatusUpdates().when(
    // Called immediately when subscribing to the stream
    progress: () => showConnectingIndicator(),
    // Called for each value emitted by the stream
    success: (update) {
      showUpdate(update);
      saveUpdateToHistory(update);
    },
    // Called when the stream emits an error
    error: (error) {
      showErrorOverlay(error.message);
      reconnectAfterDelay();
    },
    // Optional: Override the log level for this specific subscription
    logLevel: LogLevel.debug,
  );
}

Stream with null on error

// For streams, emits null instead of errors
Stream<Update?> getUpdatesWithoutBreaking() {
  return runSafetyStreamNullable(() {
    return api.updates;
  }, logLevel: LogLevel.debug); // Optional: custom log level
}

Working with Synchronous Operations

Basic synchronous error handling with Result

Result<User> getUserSync(String userId) {
  return runSafetySync(() {
    // Synchronous operation that might throw
    if (_userCache.containsKey(userId)) {
      return _userCache[userId]!;
    } else {
      throw GenericError(
        code: 'user-not-found',
        message: 'User $userId not found in cache'
      );
    }
  });
}

Using the Result type with when

void displayUserInfo(String userId) {
  final userResult = getUserSync(userId);
  
  // Using the when method for functional pattern matching
  userResult.when(
    (user) => showUserData(user), // Success case
    (error) => showError(error.message) // Error case
  );
}

Accessing Result properties directly

void processUserResult(Result<User> userResult) {
  // Check if the operation was successful
  if (userResult.isSuccess) {
    final user = userResult.valueOrNull!;
    showUserData(user);
    updateLastAccessTime();
  } else {
    // Handle the error case
    final error = userResult.errorOrNull!;
    showError(error.message);
    logFailedAttempt(error.code);
  }
}

Synchronous operation with null on error

User? getUserSafeSync(String userId) {
  return runSafetySyncNullable(() {
    return _userRepository.getUserById(userId);
  }, logLevel: LogLevel.warning);
}

Creating custom errors

import 'package:catch_and_flow/catch_and_flow.dart';

class AuthenticationError extends CustomError {
  AuthenticationError({required String message})
    : super(code: 'auth-error', message: message);
}

class NetworkError extends CustomError {
  final int statusCode;
  
  NetworkError({required this.statusCode, required String message})
    : super(code: 'network-error', message: message);
    
  @override
  List<Object?> get props => [...super.props, statusCode];
}

Advanced usage

Combining different error handlers

You can combine different error handling approaches depending on your needs:

Future<void> loadAndProcessData() async {
  // First, try to get the user from cache synchronously
  final cachedUserResult = getUserSync(currentUserId);
  
  if (cachedUserResult.isSuccess) {
    // We have a cached user, use it immediately
    processUser(cachedUserResult.valueOrNull!);
  } else {
    // No cached user, fetch from network with proper error handling
    fetchUser().when(
      progress: () => showLoading(true),
      success: (user) {
        showLoading(false);
        processUser(user);
        // Cache the user for next time
        _userCache[user.id] = user;
      },
      error: (error) {
        showLoading(false);
        showError('Failed to load user: ${error.message}');
      }
    );
  }
  
  // In parallel, start listening to updates
  getStatusUpdates().when(
    success: (update) => processUpdate(update),
    error: (error) => logWarning('Update error: ${error.message}'),
  );
}

Creating a chain of error handlers

Future<Result<UserProfile>> getUserProfile(String userId) async {
  // First try to get the user
  final userResult = await runSafetyFuture(() => api.getUser(userId));
  
  // Early return if user fetch failed
  if (userResult.isFailure) {
    return Results.error(userResult.errorOrNull!);
  }
  
  // Now try to get the profile using the user
  final user = userResult.valueOrNull!;
  return runSafetySync(() => profileService.getProfileForUser(user));
}

Learn more

For complete API documentation, see the API reference.

Libraries

catch_and_flow
The catch_and_flow library provides utilities for error handling and flow control.