Skip to content
ExponaBLOG
FLUTTER · DAY 041 / 100

A Result type with sealed classes for Flutter developers

Explicit success and failure in Dart 3.

Expona Admin6 min read

A Result type with sealed classes

If you've built any backend API integration in Flutter, you've probably caught yourself writing try-catch blocks and wondering if there's a cleaner way to handle success and failure. A Result type with sealed classes gives you exactly that: an explicit, type-safe way to represent either success or failure without hidden exceptions lurking in your UI code. In Dart 3, sealed classes make this pattern feel native to the language.

Why exceptions aren't enough

Let me be honest—throwing exceptions from your repository methods works, but it hides a contract. When you call a repository method, the caller has to read the implementation to know which exceptions might fly. In a team, that's a maintenance burden.

Consider this common pattern:

dart
class UserRepository {
  Future<User> fetchUser(String id) async {
    final response = await http.get(Uri.parse('https://api.example.com/users/$id'));
    if (response.statusCode == 200) {
      return User.fromJson(json.decode(response.body));
    } else if (response.statusCode == 404) {
      throw NotFoundException('User not found');
    } else {
      throw ServerException('Server error');
    }
  }
}

// In your UI code
try {
  final user = await repo.fetchUser('123');
  // use user
} catch (e) {
  // What exceptions could land here? You have to guess.
}

Your UI code has to be defensive, but it doesn't know what to defend against. A Result type flips this: the return type explicitly tells you success and failure are both possible, and the switch statement forces you to handle both.

Sealed class Result with Ok and Error

Here's the foundation. We define a sealed class with two subtypes: one for success, one for failure.

dart
sealed class Result<T> {}

class Ok<T> extends Result<T> {
  Ok(this.value);
  final T value;
}

class Err<T> extends Result<T> {
  Err(this.error);
  final Object error;
}

By using sealed, we tell Dart that only these two subtypes exist. No one outside this file can extend Result. That's powerful because the compiler knows the exhaustive set of cases.

Now refactor the repository:

dart
class UserRepository {
  Future<Result<User>> fetchUser(String id) async {
    try {
      final response = await http.get(Uri.parse('https://api.example.com/users/$id'));
      if (response.statusCode == 200) {
        return Ok(User.fromJson(json.decode(response.body)));
      } else if (response.statusCode == 404) {
        return Err(NotFoundException('User not found'));
      } else {
        return Err(ServerException('Server error'));
      }
    } catch (e) {
      return Err(e);
    }
  }
}

Notice: no throw, no try-catch in the caller. Errors are data. Success is data. Both travel through the same channel.

Switch forces handling both cases

The real magic happens in your UI or BLoC layer. When you use a switch on a sealed Result, Dart's analyzer enforces exhaustiveness:

dart
class UserProvider extends StateNotifier<AsyncValue<User>> {
  final UserRepository _repo;

  UserProvider(this._repo) : super(const AsyncValue.loading());

  Future<void> loadUser(String id) async {
    final result = await _repo.fetchUser(id);
    
    state = switch (result) {
      Ok(:final value) => AsyncValue.data(value),
      Err(:final error) => AsyncValue.error(error, StackTrace.current),
    };
  }
}

The compiler won't let you miss a case. If you somehow forgot the Err arm, you'd get a compile error. That's the opposite of a try-catch where you can silently ignore edge cases.

You can also use the classic switch statement:

dart
final result = await _repo.fetchUser('123');

switch (result) {
  case Ok(:final value):
    print('User: ${value.name}');
  case Err(:final error):
    print('Failed to load user: $error');
}

Or even chain transformations functionally:

dart
extension ResultExt<T> on Result<T> {
  R map<R>(R Function(T) onOk, R Function(Object) onErr) {
    return switch (this) {
      Ok(:final value) => onOk(value),
      Err(:final error) => onErr(error),
    };
  }

  Result<R> flatMap<R>(Result<R> Function(T) fn) {
    return switch (this) {
      Ok(:final value) => fn(value),
      Err(:final error) => Err(error),
    };
  }
}

// Usage
final result = await _repo.fetchUser('123');
final displayName = result.map(
  (user) => 'Hello, ${user.name}',
  (error) => 'Error: $error',
);

No hidden exceptions in UI code

One of the biggest wins: your UI widgets become exception-safe. You're not wrapping everything in try-catch to catch some random exception you didn't know about.

Before (risky):

dart
ElevatedButton(
  onPressed: () async {
    try {
      final user = await repo.fetchUser(userId);
      // use user, but repo threw 3 different exception types
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
    }
  },
  child: Text('Load User'),
)

After (explicit):

dart
ElevatedButton(
  onPressed: () async {
    final result = await repo.fetchUser(userId);
    
    if (context.mounted) {
      switch (result) {
        case Ok(:final value):
          // Guaranteed to have a valid user
          Navigator.of(context).pushNamed('/user', arguments: value);
        case Err(:final error):
          // Guaranteed to have an error, handle it
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Failed: $error')),
          );
      }
    }
  },
  child: Text('Load User'),
)

The second version is longer, but every line is intentional. There are no silent failures or surprise crashes. Your code reads like a specification.

Great for repository methods

Repository patterns shine with Result types. Each method's return type broadcasts "I can fail," and the caller knows exactly how to respond:

dart
class AuthRepository {
  Future<Result<LoginResponse>> login(String email, String password) async {
    // Network, validation, parsing—all wrapped in Result
  }

  Future<Result<void>> logout() async {
    // Storage errors, network errors—all wrapped
  }

  Future<Result<User>> refreshToken() async {
    // Token refresh failure is explicit
  }
}

class OrderRepository {
  Future<Result<List<Order>>> fetchOrders() async {}
  Future<Result<Order>> createOrder(OrderRequest req) async {}
  Future<Result<void>> cancelOrder(String orderId) async {}
}

Every repository method returns a Result. Your BLoCs and providers trust this contract. Testing is simpler because you mock Ok and Err directly without faking exceptions.

Common mistakes

  • Rethrowing inside a Result: If you catch an exception inside a repository and still throw it, you've defeated the purpose. Always return Err(e) instead. The whole point is making errors data.
  • Swallowing errors silently: Don't do case Err(_) => null; in your UI. At least log or show a snackbar. A Result forces awareness, but you still have to act on it.
  • Mixing Result and exceptions: If some methods return Result and others throw, you'll end up with inconsistent error handling. Pick one approach for a repository and stick to it. Teams thrive on consistency.
  • Over-generic error types: Storing Object in Err means you'll do runtime type checks. Consider a sealed error hierarchy if you have multiple failure modes: sealed class AppError {}; class NetworkError extends AppError {}; class ParseError extends AppError {}

Key takeaways

  • Sealed classes enforce that Ok and Err are the only subtypes, making switch exhaustive and compiler-checked.
  • Returning Result instead of throwing makes errors visible in the type signature and pushes handling to the caller.
  • Switch expressions on sealed Results eliminate try-catch boilerplate and make error paths explicit in your UI code.
  • Repository methods with Result types create a clear contract: this operation can fail, here's how you know and what to do about it.
  • Consistent use of Result across a codebase improves readability and reduces surprise exceptions in production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles