Skip to content

First-class, functional-style Result<E, T> types with built-in operators #3501

Open
@Levi-Lesches

Description

@Levi-Lesches

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:

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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problems

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions