The Problem with Imperative Error Handling

I've spent a lot of time reviewing pull requests in large Flutter and Dart codebases, and one pattern I see constantly is the "try-catch sandwich." You know the one: a massive block of code wrapped in a try-catch, where the logic is buried under layers of boilerplate, and the actual error handling is just a generic print(e) or a vague return null;.

The problem isn't that try-catch is "bad." It's fundamentally how the language works. When you throw an exception, you are effectively performing a non-local jump. You are breaking the linear flow of your program, making it harder to reason about what a function actually returns. If a function is typed as Future, but it can actually throw an Exception, the type system isn't actually protecting you. You have to remember to catch it. That's a mental tax that leads to production crashes.

In a complex application—say, a fintech app processing real-time transactions—this ambiguity is dangerous. If a network call fails, does the function return a null user, or does it crash the entire stream? Relying on documentation to tell you which errors to expect is a recipe for technical debt.

The Functional Alternative: The Result Pattern

To solve this, I've moved toward a functional approach using a Result pattern. Instead of a function saying "I will give you a User, or I might explode," we change the signature to say "I will give you a Result object, which contains either a User or an Error."

This shifts the responsibility from the developer's memory to the compiler. If a function returns a Result, you literally cannot access the success value without acknowledging the possibility of failure.

/// A simple, production-ready Result implementation.
/// Using a sealed class ensures that we handle all possible outcomes.
sealed class Result {}

/// Represents a successful operation.
class Success extends Result {
  final S value;
  Success(this.value);
}

/// Represents a failed operation with an error of type E.
class Failure extends Result {
  final E error;
  Failure(this.error);
}

Real-World Implementation: The Repository Pattern

Let's look at a practical scenario. Imagine we are building a repository that fetches user profiles from a REST API. We need to handle two distinct types of failures: NetworkError (connectivity issues) and ServerError (404 or 500 errors).

// Define our specific error types
sealed class RepositoryError {}
class NetworkError extends RepositoryError {}
class ServerError extends RepositoryError { final int code; ServerError(this.code); }

class User { final String id; final String name; User(this.id, this.name); }

class UserRepository {
  final ApiClient _client;
  UserRepository(this._client);

  /// Notice the return type: Result.
  /// This tells the caller exactly what to expect.
  Future<Result<User, RepositoryError>> getUserProfile(String userId) async {
    try {
      final response = await _client.get('/users/$userId');
      
      if (response.statusCode == 200) {
        return Success(User(response.data['id'], response.data['name']));
      } else {
        // Map HTTP errors to our domain-specific errors
        return Failure(ServerError(response.statusCode));
      }
    } on SocketException {
      // Catch specific low-level exceptions and wrap them
      return Failure(NetworkError());
    } catch (e) {
      // Fallback for unexpected errors
      return Failure(ServerError(500));
    }
  }
}

Why This Wins: Pattern Matching

The real magic happens when you consume this code. Since we used a sealed class for Result, Dart's pattern matching (introduced in 3.0) allows us to handle the result exhaustively. If you add a new error type to your RepositoryError class later, the compiler will actually throw an error in every place you use a switch statement, telling you that you haven't handled the new case.

void handleProfileUpdate(UserRepository repo, String id) async {
  final result = await repo.getUserProfile(id);

  // The compiler forces us to consider both Success and Failure
  final message = switch (result) {
    Success(value: final user) => "Welcome back, ${user.name}!",
    Failure(error: NetworkError()) => "Please check your internet connection.",
    Failure(error: ServerError(code: final c)) => "Server error occurred (Code: $c).",
  };

  print(message);
}

The Senior Dev's Takeaway

When I'm architecting a new module, I ask myself: "Can this function fail?" If the answer is yes, the type signature must reflect that.

Pro-tip: Don't use Result. Always define your error types using sealed classes. This turns runtime crashes into compile-time tasks.

By adopting this pattern, you achieve several things:

  • Self-documenting code: The function signature tells the whole story.
  • Type safety: You can't accidentally use a null or undefined value because the data is wrapped.
  • Exhaustiveness checking: The compiler becomes your unit tester, ensuring you've handled every edge case.

It takes a little more typing upfront to define your Result and Error classes, but the amount of time you save during debugging and refactoring is immense. It's the difference between chasing a NullThrownError in production and having the compiler tell you "Hey, you forgot to handle the NetworkError case" while you're still writing the code.