/freezed_result

A Flutter Result type that feels like a Freezed union.

Primary LanguageDartMIT LicenseMIT

Freezed Result

A Result<Success, Failure> that feels like a Freezed union. It represents the output of an action that can succeed or fail. It holds either a value of type Success or an error of type Failure.

Failure can be any type, and it usually represents a higher abstraction than just Error or Exception. It's very common to use a Freezed Union for Failure (e.g. AuthFailure) with cases for the different kinds of errors that can occur (e.g. AuthFailure.network, AuthFailure.storage, AuthFailure.validation).

Because of this, we've made Result act a bit like a Freezed union (it has when(success:, failure:)). The base class was generated from Freezed, then we removed the parts that don't apply (maybe*) and adapted the others (map*) to feel more like a Result. We'll get into the details down below.

Usage

There are 3 main ways to interact with a Result: process it, create it, and transform it.

Processing Values and Errors

Process the values by handling both success and failure cases using when. This is preferred since you explicitly handle both cases.

final result = fetchPerson(12);
result.when(
  success: (person) => state = MyState.personFound(person);
  failure: (error) => state = MyState.error(error);
);

Or create a common type from both cases, also using when.

final result = fetchPerson(12);
final description = result.when(
  success: (person) => 'Found Person ${person.id}';
  failure: (error) => 'Problem finding a person.';
);

Or ignore the error and do something with maybeValue, which returns null on failures.

final person = result.maybeValue;
if (person != null) {}

Or ignore both the value and the error by simply using the outcome.

if (result.isSuccess) {}
// elsewhere
if (result.isFailure) {}

Or throw failure cases and return success cases using valueOrThrow.

try {
  final person = result.valueOrThrow();
} on ApiFailure catch(e) {
  // handle ApiFailure
}

Creating Results

Create the result with named constructors Result.success and Result.failure.

Result.success(person)
Result.failure(AuthFailure.network())

Declare both the Success and Failure types with typed variables or function return types.

Result<Person, AuthFailure> result = Result.success(person);
Result<Person, AuthFailure> result = Result.failure(AuthFailure.network());
Result<Person, FormatException> parsePerson(String json) {
  return Result.failure(FormatException());
}

Results are really useful as return values for async operations.

Future<Result<Person, ApiFailure>> fetchPerson(int id) async {
  try {
    final person = await api.getPerson(12);
    return Result.success(person);
  } on TimeoutException {
    return Result.failure(ApiFailure.timeout());
  } on FormatException {
    return Result.failure(ApiFailure.invalidData());
  }
}

Sometimes you have a function which may have errors, but returns void when successful. Variables can't be void, so use Nothing instead. The singleton instance is nothing.

Result<Nothing, DatabaseError> vacuumDatabase() {
  try {
    db.vacuum();
    return Result.success(nothing);
  } on DatabaseError catch(e) {
    return Result.failure(e);
  }
}

You can use catching to create a success result from the return value of a closure. Unlike the constructors, you'll need to await the return value of this call.

Without an explicit type parameters, any Object thrown by the closure is caught and returned in a failure result.

final Result<String, Object> apiResult = await Result.catching(() => getSomeString());

With type parameters, only that specific type will be caught. The rest will pass through uncaught.

final result = await Result.catching<String, FormatException>(
  () => formatTheThing(),
);

Transforming Results

Process and transform this Result into another Result as needed.

map

Change the type and value when the Result is a success. Leave the error untouched when it's a failure. Most useful for transformations of success data in a pipeline with steps that will never fail.

Result<DateTime, ApiFailure> bigDay = fetchPerson(12).map((person) => person.birthday);

mapError

Change the error when the Result is a failure. Leave the value untouched when it's a success. Most useful for transforming low-level exceptions into more abstact failure classes which classify the exceptions.

Result<Person, ApiError> apiPerson(int id) {
  final Result<Person, DioError> raw = await dioGetApiPerson(12);
  return raw.mapError((error) => _interpretDioError(error));
}

mapWhen

Change both the error and the value in one step. Rarely used.

Result<Person, DioError> fetchPerson(int id) {
  // ...
}
Result<String, ApiFailure> fullName = fetchPerson(12).mapWhen(
    success: (person) => _sanitize(person.firstName, person,lastName),
    failure: (error) => _interpretDioError(error),
);

mapToResult

Use this to turn a success into either another success or to a compatible failure. Most useful when processing the success value with another operation which may itself fail.

final Result<Person, FormatError> personResult = parsePerson(jsonString);
final Result<DateTime, FormatError> bigDay = personResult.mapToResult(
  (person) => parse(person.birthDateString),
);

Parsing the Person may succeed, but parsing the DateTime may fail. In that case, an initial success is transformed into a failure. Aliased to flatMap as well for newcomers from Swift.

mapErrorToResult

Use this to turn an error into either a success or another error. Most useful for recovering from errors which have a workaround.

Here, mapErrorToResult is used to ignore errors which can be resolved by a cache lookup. An initial failure is transformed into a success whenever the required value is available in the local cache. The _getPersonCache function also translates both unrecoverable original DioErrors, and any internal errors accessing the cache, into the more generic FetchError.

final Result<Person, DioError> raw = await dioGetApiPerson(id);
final Result<Person, FetchError> output = raw.mapErrorToResult((error) => _getPersonCache(id, error));

Result<Person, FetchError> _getPersonCache(int id, DioError error) {
  // ...
}

Aliased to flatMapError for Swift newcomers.

mapToResultWhen

Rarely used. This allows a single action to both try another operation on a success value which may fail in a new way with a new error type, and to recover from any original error with a success or translate the error into the new type of Failure.

Result<Person, DioError> fetchPerson(int id) {
  // ...
}
Result<String, ProcessingError> fullName = fetchPerson(12).mapToResultWhen(
    success: (person) => _fullName(person.firstName, person,lastName),
    failure: (dioError) => _asProcessingError(dioError),
);

Aliased to flatMapWhen, though Swift doesn't have this equivalent.

Alternatives

  • Result matches most of Swift's Result type.
  • result_type which fully matches Swift, and some Rust.
  • fluent_result allows multiple errors in a failure, and allows custom errors by extending a ResultError class.
  • Dartz is a functional programming package whose Either type can be used as a substitute for Result. It has no concept of success and failure. Instead it uses left and right. It uses the functional name fold to accomplish what we do with when.
  • error_or is focused more on error handling, and defines only the success type; failure is always Object.
  • result_class similar to Rust result.
  • result_monad also modeled on Rust, but with a strong focus on mapping.
  • rust_like_result also inspired by Rust.
  • simple_result inspired by Swift and Freezed. Also uses when like freezed_result.
  • Super Enum is a library with a larger goal, but it shows how to roll your own Result with the library.