-
Notifications
You must be signed in to change notification settings - Fork 214
First-class, functional-style Result<E, T>
types with built-in operators
#3501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
It's similar to nullability because both are union types, and both have a second part which is mostly untyped. (You could type the error, but most of the time you just want to know that it's not the value. And you don't really want the supertype to have to carry an error type too.) Which is a different way of saying that they're both monads. The null monad and the error monad are both classical monads. The difference is that And errors have always, mostly, been control flow, even when we capture them in async, they're intended to be reified as control flow and caught by So this is, effectively, asking for an official
Not sure we really do need language features for most of these. The (Also, |
An official implementation should also ensure that it integrates nicely with the existing language:
In Rust a problem with Results is that your custom error types often need an Any variant for errors you just want to bubble up. |
I would use Results exactly when the Error type is not untyped, but either an enum or a sealed class that allows me to handle the errors. If I don't care about the error type, in Dart I'd either use nullables directly or throw exceptions.
Not sure what you mean, but I'd argue that Results are much better for control flow as they work with the new pattern matching features, traditional if statements and could also be integrated with the collection APIs In general: Exceptions work pretty badly with collections and iterators (will the iterator continue running after an exception was thrown or will it throw itself? will it return the first exception or all exceptions)) -- A Result type would be great here!
No, to the contrary, FutureOr shouldn't exist
The language features, like the ? and ! operators would be pretty important and the lint when ignoring error cases would be absolutely critical. |
Pretty much, yeah. In cases where you do need to know the error type, you couldn't use the
I see a difference here. I see Result types as corresponding to
Yes to the first two but probably not the last one (unless you mean I guess in that way, i see Exceptions as similar to nullables for another reason: we used to have lots of null checks everywhere and throw an error if we found once, but post null-safety we can simply
The operators would be nice to have, and as I demonstrated they can all be written as methods today including
I'd disagree again and say that Result types should almost never translate to thrown errors in the ideal case (barring a quick-and-dirty I agree with throwing --> Result, but I don't see the need for Result --> nullable (especially if you could just do |
Result<E, T>
typesResult<E, T>
types with built-in operators
That's a convention, the language doesn't care. The only special casing is for objects which extend the A language feature should be able to handle any object that is thrown, so anything other than |
I think that with extension types, we'll see new implementations of the existing Consider something like this (slightly hackish) version; |
Not sure I see the practical difference between extension types over sealed types in terms of actual handling (other than that devs can make their extension types off of And it's precisely because packages can and have been implementing their own versions that I feel it should be standardized as early as possible. Because with the current fragmentation, the result types from two packages are very unlikely to be compatible. Also just a note that
I don't think the Result<String?, User> getUser(); // returns the reason this failed, or null if some unknown error occurred
void main() => getUser().match(
onError: (reason) => print(reason ?? "Something happened"),
onValue: (user) => print(user.value),
); The point of my distinction between |
The difference is it is free (wrapperless), much more similar to Here is your example converted roughly https://dartpad.dev/?id=12c5ce9064357d6bb370cb2027612aa8&channel=master |
I've been writing more and more Rust lately due to Advent of Code and it really makes me appreciate a strong |
With full language support, there is no need to change APIs. Let's say we introduce a postfix operator, strawman syntax Then we don't need to change |
I'm more fond of a prefix like I think the goal of having a core |
I don't see us changing existing APIs to return a And I don't see us writing new APIs that way either, to be honest. It would be a massive change of direction to do that. Which is a way of saying that a feature like this doesn't just need to argue for its usability and advantage over the status quo, it also needs to show a migration path with a cost that doesn't outweigh the benefits. And adding the type won't remove any ambiguity over whether a function can throw an exception, not unless we also remove the ability to throw anything but errors. |
Just adding a Result with the expected basic operations would be enough as a first step imo. More isn't needed for now imo. If the community adopts this and starts using it everywhere, there could still be a discussion about migrating things or adding features to ease migration. |
People are okay with that compromise, considering they have already started using Result types in their projects. If I was authoring a package with Result types, on the API borders, I'd catch all exceptions and map them to variants of my Err type. |
Again, to clarify, I'm not trying to introduce checked exceptions here. That's why I was making the point about
Maybe that's a nice direction Dart could've gone in, but by now it's a bit late to make such a change. Result types would be for package authors and app developers to safely write code that doesn't need to throw. Maybe it's a little more work for a package author to wrap all their server calls in try/catches to get a Result type, but then all users of that package can focus on handling the error conditions the package author deemed important, instead of also blindly catching everything. This isn't to prevent Dart code from throwing anymore, it's to turn more error conditions into regular values and allow us to use expressions to handle those errors safely, rather than relying on control-based try/catch. Also, I'd readily argue that the value of such a feature is the standardization, due to how many packages separately implement this type and are therefore incompatible.
The Dart SDK itself doesn't need to commit to using them, but simply exposing them to the Dart/Pub ecosystem would do wonders for compatibility of current and future packages, and allow their users to get more benefit out of them. |
We (dart) could adopt syntactical sugar for result/error of |
Syntactic sugar is imo not important at this point; just getting a good plain old dart class / type would already be very useful. |
How is it different ? (Assuming dart equivalent of checked exceptions would also declare string -or others- if one was thrown.) Unlike an exception, result isn't used as a control flow. Anything beside that ? |
Checked exceptions involve not being able to use A So this would also allow throwing and returning results to coexist. Mostly within an API, eg, throwing The "first-class" part of this proposal would make result types even more convenient by allowing some neat built-in methods or syntax sugar to interact with result types and quickly get values out of them and handle errors. Try/catch is more restrictive and verbose, and while an inline try/catch expression would treat some of these cases, it won't treat all. |
I mean, it does not exist in Dart, so there are opportunities for better design choices. Couldn't dart infer the exceptions for example ? And just hint the user that not all exceptions are caught on the other end when that's the case instead of failing to compile. You also need to declare Result<S, E> as a return value too.
Afaik, there is no consensus that this would be a good thing to begin with (and I personally don't think so). ( Way out of my depth here but maybe Djikistra hatred of go-to is the consensus ? In any case, they are convenient) |
The question is where is the "other end"? Very few functions/methods in an app are called in
No because -- like you said -- there's no reason to rewrite all your throwing code in terms of
Of course, I meant ideally from my perspective and use-case, for sure not for everyone. I even acknowledged several times that I still think |
Small nitpick: in most languages I used, the "expected" return type is the first and the "error" return type is the second in the generic ( Besides the small nitpick, this would be amazing! There's already appetite from the community to use such a Result type, and having a built in one would make it so that there aren't any clashes between different implementations. Some of the built in operators talked about here aren't that necessary, but Dart currently has no good way to represent an operation that can be expected to fail, besides using exceptions. For example, |
You don't need classes to return "result or error". Here's my attempt at a zero-cost implementation using extension types. // definitions
extension type Maybe<T>((T? v, Exception? e) pair) {
Exception? get error => pair.$2;
T get value =>
error != null ? pair.$1 as T : throw "no value, check the error";
}
Maybe<T> ok<T>(T val) => Maybe((val, null));
Maybe<Never> error(String msg) => Maybe((null, Exception(msg)));
// returning Maybe from function
Maybe<int> foo(String msg) {
return (msg == "Hello") ? ok(1) : error("wrong parameter");
}
// testing it
main() {
var x = foo("Hello");
var y=foo("");
if (x.error != null) {
throw x.error;
}
x.value;
y.value; // expected to throw
} |
Is a tuple any more zero cost than a class? |
My understanding is that a tuple doesn't create an object on the heap, it allocates on the stack. In terms of allocation/deallocation overhead, 2-tuple is (almost) equivalent to int. |
@tatumizer The main, most important point of this issue, is the standardization of such a type. I personally do not care if it's union types, sealed wrapper classes, extension types, records, or even a new type like But while we can write extensions and new types ourselves, no amount of individual or third-party effort can resolve the fragmented ecosystem of packages that all try to solve the same problem in incompatible ways. Like I said in the Motivation section:
|
What is exactly the problem with this? Everyone uses what they like the most. Why would we need to have a standardized Also, what do you mean with "be compatible" regarding these packages? If I depend on A and B and each one of them uses a different Personally, I am much more fond of maybe-like monads than exceptions. IMO, exceptions should not have to exist at all. However, this is how Dart was designed and it would be massively breaking to change it now, so I just learned to live with exceptions. Having both would be actually very confusing and fragment the community even more. I do not encourage package authors to use |
there are multiple packages on pub with exactly this
people are already using custom result types. standardizing one type would reduce fragmentation, not add to it.
this is not about removing anything; and exceptions ARE useful. this is about standardizing and simplifying the ecosystem.
people now have to write mappers to pass results between packages. results and exceptions don't really play nice together (here the language could provide some syntactial sugar or special features for interacting between the two concepts)
TLDR: good things aren't possible. |
There shouldn't be. We should strive to be consistent in using exceptions instead of building an inconsistent ecosystem that uses both.
Sure, use it in your apps or internally in your packages. Having a first-class
This is the problem. Not removing exceptions and adding This is why most languages that were designed with builtin maybe monad doesn't have an exception system.
This is why we shouldn't have
Yes, good things are possible. A good thing would be to remove exceptions system completely in favor of an explicit and sound maybe monad system. I would be in favor of that. But it comes with a high cost that the Dart team (understandably) don't want to pay. Having both |
I don't agree that exceptions are the only way to handle unexpected results. For example, when you can't find a user in a database, some packages will return null while others throw. Protobuf throws errors when it can't decode a message, but otherwise never uses null to represent empty messages, instead using a system of default values. The point is, that there is no "one way" to handle every possible case of failure, and result types would just be another tool in our belts. A tool that people find themselves making from first principles quite often, because there is no standard. Also, I'd disagree on using the collective "we" like that. My apps don't influence the existence or maintenance of
How is using a standard, first-class type "fragmenting" the ecosystem? Is using
The advantage is precisely that you get another way to solve your problems. As I started off saying, exceptions are not and should not be the only way to indicate failure or lack of data, so having a type-safe and convenient alternative that forces you to consider the failure cases is something people want, and are consistently making and using. |
Why do you need "another tool" for that ?
Because currently the ecosystem is using mostly exceptions. Sure, there might be some devs / library here and there that might use a
You can make the same argument about checked exceptions. |
(Just for the record: "My understanding is that a tuple doesn't create an object on the heap" is not necessarily true. Tuples have the option of not allocating an object, but any time a tuple is stored in some place that can also store objects, it will create an object. That includes storing it in any variable that isn't typed at the same record shape, which also includes any generic types. Well, unless that code is inlined and specialized to the tuple type. Which means you can't know for sure.) |
(Damn... I have already advertised this Maybe tuple as a zero-cost device... What an embarrassment! What should I do now? Post a correction? Or perhaps no one believed my hype in the first place, so there's nothing to correct? :-) |
In principle, you can use exceptions even for fine-grained error handling, without unnecessary try-catch boilerplate. T? tryOrNull<T>(T f(), [void handler(Exception ex)?]) {
try {
return f();
} on Exception catch (e) {
if (handler != null) {
handler(e);
}
return null;
}
}
main() {
var x=tryOrNull(()=>5);
var y=tryOrNull(()=> throw Exception(), (e)=>print(e));
print(x);
print(y);
} As a bonus, in the handler, you can use pattern matching, which is arguably more convenient than "catch" - see #3673. I wonder how the languages that don't support exceptions live without stack traces. By all accounts, no one complains. |
Here's rust_core's Result type with the concept of an early return. Future<void> foo() async {
final result = await Result.async(($) async {
// service.fetchData() returns a Result, but when using the early return,
// it returns the data, or exits early (internally it throws an exception which is handled by Result.async)
final ApiData bar = await service.fetchData()[$];
// ...
});
// ...
} If we do get a similar Result type in Dart, it definitely should have support for this, but in a cleaner way. Here's how I imagine it. Future<Result<ApiData, ApiError>> foo() async {
final ApiData bar = try await service.fetchData(); // propagates the error as long as the error type of Result is a supertype of the error, otherwise it's a compile time error
// ...
} |
Uh oh!
There was an error while loading. Please reload this page.
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
):Usage would look like:
Possible lints
Seeing as you could forget to handle an error, this should probably come with a lint against cases like the following:
Another lint could be
Warning: Avoid returning nullable Result types. Use a nullable
T
type insteadBAD:
GOOD:
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 theResult
pattern.In fact, code in the wild already does use this style:
package:fpdart
'sEither
classpackage:either
'sEither
classpackage:async
'sResult
classeither
,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 aResult<String, User>
and aUser?
:By thinking a little harder, we can get an analog to the
?.
operator:Thoughts?
The text was updated successfully, but these errors were encountered: