Result Data/Error wrapper in Dart for Flutter

Β·

4 min read

A very common way of handling responses that either return data or error in Swift is using a Result<S> enum object, or Data Classes in Kotlin.

This type of data control is especially nice for data source requests, such as network, DB, etc, where it's not unusual to receive errors instead of the expected data. By wrapping the result and ensuring all errors will be returned in this object instead of being thrown, we can have different approaches to certain scenarios.

For example, when working with Streams/RxDart, errors thrown automatically close the streams. This might not be exactly what you're looking for if you have persistent streams which need to be kept alive even when things go wrong.

Data what?

Whilst Dart doesn't have Data Classes, I came across a package called Either, which provides a similar solution to what I'm already used to. However, I wanted to make it a bit more specific to my needs, and less abstract in the sense of "left" and "right", as well as not needing to specify the type of error for each result, which makes the code way too verbose and hard to read for my usage.

Here's a simple definition of this helper's interface:

abstract class Failure implements Exception {
  @override
  String toString() => '$runtimeType Exception';
}

// General failures
class GenericFailure extends Failure {}
class APIFailure extends Failure {}

abstract class DataResult<S> {
  static DataResult<S> failure<S>(Failure failure);
  static DataResult<S> success<S>(S data);

  /// Get [error] value, returns null when the value is actually [data]
  Failure? get error;

  /// Get [data] value, returns null when the value is actually [error]
  S? get data;

  /// Returns `true` if the object is of the `SuccessResult` type, which means
  /// `data` will return a valid result.
  bool get isSuccess;

  /// Returns `true` if the object is of the `FailureResult` type, which means
  /// `error` will return a valid result.
  bool get isFailure;

  /// Returns `data` if `isSuccess` returns `true`, otherwise it returns
  /// `other`.
  S dataOrElse(S other);
}

This isn't the complete implementation (you'll find it here), but it gives you enough context for its usage. DataResult will either be initialised with a success data or a failure error of the type Failure, which is a custom Exception that can be extended to any specific error your app might have, for example APIFailure.

Here's an example of a network request which maps the result into a DataResult<User>:

class UserClient {
  ... // pretend all properties and such are here

  Future<DataResult<User>> createUser(String email, String name) async {
    final body = {'username': email, 'name': name};

    try {
      final json = await client.post('/users', body);
      return DataResult.success(User.fromJson(json));
    } catch (error) {
      return DataResult.failure(error is Failure ? error : APIFailure());
    }
  }
}

Now the caller can easily handle the result:

final client = UserClient();
final result = await client.createUser('foor@email.com', 'Foo Bar');

if (result.isSuccess) {
  // do something with result.data
} else {
  // do something with result.error
}

Next steps

We can make DataResult even more interesting by applying custom mapping and transforming methods to it, like Either did (thanks, Avdosev!):

  // PS: these are interface methods, the full implementation can be seen at the end of the article

  /// Transforms values of [error] and [data] in new a `DataResult` type. Only
  /// the matching function to the object type will be executed. For example,
  /// for a `SuccessResult` object only the [fnData] function will be executed.
  DataResult<T> either<T>(
      Failure Function(Failure error) fnFailure, T Function(S data) fnData);

  /// Transforms value of [data] allowing a new `DataResult` to be returned.
  /// A `SuccessResult` might return a `FailureResult` and vice versa.
  DataResult<T> then<T>(DataResult<T> Function(S data) fnData);

  /// Transforms value of [data] always keeping the original identity of the
  /// `DataResult`, meaning that a `SuccessResult` returns a `SuccessResult` and
  /// a `FailureResult` always returns a `FailureResult`.
  DataResult<T> map<T>(T Function(S data) fnData);

  /// Folds [error] and [data] into the value of one type. Only the matching
  /// function to the object type will be executed. For example, for a
  /// `SuccessResult` object only the [fnData] function will be executed.
  T fold<T>(T Function(Failure error) fnFailure, T Function(S data) fnData);

As an example, we can apply the either transform methods to our previous example, instead of an if/else conditionals:

result.either(
        (error) => // do something with error,
        (data) => // do something with data,
      );

Conclusion

This simple class and its handy transform methods can make your life much easier, without having to handle try catches everywhere, but as everything in life, use it wisely ☺️.

I hope you liked this post! More examples can be seen in my gist's tests.

Thanks for reading and let me know what you think. Feel free to message me up on Twitter too!