Description
Proposal
With sealed classes and exhaustive pattern matching, I think we've hit a natural time to add first-class result types to the language, or at least the first-party libraries (the ones published by dart.dev
):
sealed class Result<E, T> {
R match<R>({
required R Function(E) onError,
required R Function(T) onValue,
}) => switch (this) {
Failure(error: final error) => onError(error),
Success(value: final value) => onValue(value),
};
T onError(T Function(E) onError) => switch (this) {
Failure(error: final error) => onError(error),
Success(value: final value) => value,
};
T assertErrors() => switch (this) {
Failure(error: final error) => throw StateError("Result type had an error value: $error"),
Success(value: final value) => value,
};
}
class Failure<E, T> extends Result<E, T> {
final E error;
Failure(this.error);
}
class Success<E, T> extends Result<E, T> {
final T value;
Success(this.value);
}
Usage would look like:
Result<String, int> getNum() =>
Random().nextBool() ? Success(123) : Failure("Oops!");
void main() {
// Simply handle each case
getNum().match(
onError: (reason) => print("Couldn't get number because $reason"),
onValue: (value) => print("Got number! ${value + 1}"),
);
// Use a value based on the presence of an error
final number = getNum().match(
onError: (error) => 0,
onValue: (value) => value + 1,
);
// or if you just want to handle errors (like ?? in null safety)
final num2 = getNum().onError((error) => 0);
// if you believe there are no errors (like ! in null safety)
final num3 = getNum().assertErrors();
}
Possible lints
Seeing as you could forget to handle an error, this should probably come with a lint against cases like the following:
void main() {
getNum(); // Warning: Avoid using Result types without handling the error case
}
Another lint could be
Warning: Avoid returning nullable Result types. Use a nullable T
type instead
BAD:
Result<ErrorType, User>? getUser() => isPresent(user) ? Success(user) : null;
GOOD:
Result<ErrorType, User?> getUser() => Success(isPresent(user) ? user : null);
Motivation
Besides the fact that this style is a pretty popular alternative to try/catch-ing errors that functions may throw, this solves the concrete problem of "how can I be sure this function won't crash my app?". By returning a Result
type, you're signaling to the caller that all error cases can be handled safely by using pattern matching or a method like .onError
. With more and more of the ecosystem using a style like this, code will be safer at runtime. As mentioned above, sealed classes and exhaustive pattern matching allow us to handle this safely for the first time, so this would be a natural time to officially adopt the Result
pattern.
In fact, code in the wild already does use this style:
package:fpdart
'sEither
classpackage:either
'sEither
classpackage:async
'sResult
class- ...and the countless other packages seen by searching
either
,result
, oroption
As much as this "could be handled by a package", right now the ecosystem is fragmented because it is being handled by packages, and each package is making their own, meaning the chances of any two packages being compatible are slim to none. Sure, we could make a "better" package, but that probably wouldn't help,. By making this first-party, it is more likely that other packages will contribute and migrate to the canonical implementation, increasing compatibility across the ecosystem.
Another thing is that without being a language feature or recognized first-party package, we miss out on valuable lints.
Possible language/syntax improvements
If this is made a first-class language feature, not just package, we can go a little further. Above, I noted that Result.onError
was like ??
in the sense that it says "if there is an error/null, ignore it and use this value instead". Result.assertErrors
is like !
in that it says "I don't expect there to be an error/null, so if there is, please throw an actual error and stop execution".
I believe those concepts are similar enough to null safety that they shouldn't be considered semantic overloading, while also being different enough to not be confused with actual null safety (the above lints would protect against Result?
messiness).
To further demonstrate, compare these two getUser
functions, which return a Result<String, User>
and a User?
:
class User {
String get name => "John Smith";
}
Result<String, User> getUser1() =>
Random().nextBool() ? Success(User()) : Failure("Could not get user");
User? getUser2() => Random().nextBool() ? User() : null;
void main() {
final name1 = getUser1().assertErrors().name;
final user1 = getUser1().onError((_) => User());
final name2 = getUser2()!.name;
final user2 = getUser2() ?? "Default name";
}
By thinking a little harder, we can get an analog to the ?.
operator:
sealed class Result<E, T> {
Result<E, R> chain<R>(R Function(T) func) => switch (this) {
Failure(error: final error) => Failure(error),
Success(value: final value) => Success(func(value)),
};
}
// Same getUser1 and getUser2 as above
void main() {
final name3 = getUser1().chain((user) => user.name);
final name4 = getUser2()?.name;
}
Thoughts?