A Flutter Result type that feels like a Freezed union
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());
}
Result
s 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 DioError
s, 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 forResult
. It has no concept of success and failure. Instead it usesleft
andright
. It uses the functional namefold
to accomplish what we do withwhen
.