language icon indicating copy to clipboard operation
language copied to clipboard

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

Open Levi-Lesches opened this issue 1 year ago • 36 comments

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?

Levi-Lesches avatar Dec 08 '23 08:12 Levi-Lesches

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 null has always been a value in Dart, not part of control flow. We don't have a "no value" type, like fx C's void type, which null would be a reflection of.

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 catch.

So this is, effectively, asking for an official ErrorOr union type, and monadic operations on it, probably similar to those of both nullable types and FutureOr.

  • e?.useValue preserves the error, or operates on the value.
  • e ?? e2 uses the value of e, or ignores the error and uses the value of e2.
  • awaitish e evaluates to the value or re-raises the error (reifies the capture control flow).

Not sure we really do need language features for most of these. The ?. is the one that I haven't found a good way to do (in the time writing this). The rest are easy to get to without to much extra writing.

(Also, package:async is published by dart.dev.)

lrhn avatar Dec 08 '23 09:12 lrhn

An official implementation should also ensure that it integrates nicely with the existing language:

  • The error variant should carry stack traces -- maybe allow the Error variant to only contain types that inherit from Dart exceptions?
  • There should be a function that maps a throwing function to one that returns a Result
  • There should be a function that maps a result to a nullable
  • Unwrapping the Error variant should make in the StackTrace / error clear, where the original Error happened and where the unwrap took place
  • Documentation should clarify where Result / Either makes sense and where exceptions make more sense (stuff like division by zero for example)

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. Fortunately Dart already has a solution for that: throwing Exceptions.

benthillerkus avatar Dec 08 '23 09:12 benthillerkus

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.)

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.

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 catch.

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!

So this is, effectively, asking for an official ErrorOr union type, and monadic operations on it, probably similar to those of both nullable types and FutureOr.

  • e?.useValue preserves the error, or operates on the value.
  • e ?? e2 uses the value of e, or ignores the error and uses the value of e2.
  • awaitish e evaluates to the value or re-raises the error (reifies the capture control flow).

No, to the contrary, FutureOr shouldn't exist

(Also, package:async is published by dart.dev.)

The language features, like the ? and ! operators would be pretty important and the lint when ignoring error cases would be absolutely critical. If it was added as a package it should be included in the sdk by default.

benthillerkus avatar Dec 08 '23 09:12 benthillerkus

@lrhn

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.)

Pretty much, yeah. In cases where you do need to know the error type, you couldn't use the ?? operator and its friends -- you'd have to use .match()

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 catch.

I see a difference here. Errors are meant to be thrown and stop the flow of the program. Those are control flow entities. But Exceptions were always meant to be handled. I'd argue the fact that you throw them isn't as important as the fact that you hope someone else will eventually catch it. In an ideal user-facing application, all or most Exceptions would be handled.

I see Result types as corresponding to Exceptions, and therefore a value, while I still see throw as a tool to surface Errors, which therefore affects control flow.

So this is, effectively, asking for an official ErrorOr union type, and monadic operations on it, probably similar to those of both nullable types and FutureOr.

  • e?.useValue preserves the error, or operates on the value.
  • e ?? e2 uses the value of e, or ignores the error and uses the value of e2.
  • awaitish e evaluates to the value or re-raises the error (reifies the capture control flow).

Yes to the first two but probably not the last one (unless you mean e!), because again I don't see exceptions as needing to stop control flow, I see them as signals that need to be interpreted, like how nulls once were. The only reason I'd suggest e! is because someone will want to eg, make an HTTP request and just crash the program if the status code isn't a clean 200. But in the ideal case, e! should never be used, and the exception should instead be handled (eg, a print statement or a Flutter Text).

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 ?. and ?? it away. What once greatly affected control flow is now just an expression. I believe if Result types catch on, the same would be true for Exceptions. Yes, IndexErrors should probably still break your program, but now server errors won't.

Not sure we really do need language features for most of these. The ?. is the one that I haven't found a good way to do (in the time writing this).

The operators would be nice to have, and as I demonstrated they can all be written as methods today including ?., but IMO the standardization is way more important. Yes, package:async is first-party but its Result class is not advertised as a true Result class (and even if it were, I'd be happy to pretty much just move it somewhere more broad than async.

@benthillerkus

  • The error variant should carry stack traces -- maybe allow the Error variant to only contain types that inherit from Dart exceptions?

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 e! syntax), but the whole point of them is to always be guaranteed to be handled safely in-app, in a way that makes sense for your specific use-case.

I agree with throwing --> Result, but I don't see the need for Result --> nullable (especially if you could just do e ?? null).

Levi-Lesches avatar Dec 08 '23 10:12 Levi-Lesches

I see a difference here. Errors are meant to be thrown and stop the flow of the program. Those are control flow entities. But Exceptions were always meant to be handled

That's a convention, the language doesn't care. The only special casing is for objects which extend the Error class. After throwing, they're all just objects.

A language feature should be able to handle any object that is thrown, so anything other than null.

lrhn avatar Dec 08 '23 14:12 lrhn

I think that with extension types, we'll see new implementations of the existing Result (and Option) types, and probably other stable helper types.

Consider something like this (slightly hackish) version; https://dartpad.dev/?id=4daa370e9f33c8562ca3efe06e8e5adf&channel=master

lrhn avatar Dec 08 '23 17:12 lrhn

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 Result I guess), but yes the point is that with exhaustive pattern matching we've reached a point where these classes are possible idiomatically.

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 Optional<T> types isn't what I'm looking for here as I believe null safety is enough in these cases. It's Result<E, T> that handles a possible error value. And to address

A language feature should be able to handle any object that is thrown, so anything other than null.

I don't think the E type necessarily needs to or should be thrown. I can imagine

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 Errors and Exceptions wasn't to constrain it to the E extends Exception, but more philosophically about how Result types turn a control-based throw/try/catch into an expression-based .match,/.onError (or even simpler, ??/?./!). That's also why I didn't get what you meant by awaitish earlier -- I'm not saying "defer this exception but maybe throw it", but rather "this Result subclass will definitely be a Success type, but if it's a Failure then throw".

Levi-Lesches avatar Dec 08 '23 19:12 Levi-Lesches

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 Result I guess), but yes the point is that with exhaustive pattern matching we've reached a point where these classes are possible idiomatically.

The difference is it is free (wrapperless), much more similar to FutureOr today.

Here is your example converted roughly https://dartpad.dev/?id=12c5ce9064357d6bb370cb2027612aa8&channel=master

jakemac53 avatar Dec 08 '23 19:12 jakemac53

I've been writing more and more Rust lately due to Advent of Code and it really makes me appreciate a strong Result type and I definitely would love to see an official version with language support in Dart. This would require changing official APIs like num.parse, jsonDecode/Encode, etc. A migration period might look like num.parseResult() or num.parseWithResult(), etc but that's probably for another issue.

Reprevise avatar Dec 08 '23 21:12 Reprevise

With full language support, there is no need to change APIs.

Let's say we introduce a postfix operator, strawman syntax catch, which converts an expression of type T to an expression of type Result<T>, and captures either a value or a throw. And we allow ! to convert Result<T> to T like suggested.

Then we don't need to change int.parse, you just write var r = int.parse(string) catch;. (Hmm, might look better as prefix, like catch(e).)

lrhn avatar Dec 09 '23 20:12 lrhn

(Hmm, might look better as prefix, like catch(e).)

I'm more fond of a prefix like try. So for example, you'd do var r = try int.parse(string).

I think the goal of having a core Result class should be to remove the ambiguity of determining whether or not a method can "throw" an exception. People would just shove try (or whatever the syntax becomes) for every method, even when unnecessary. I think that'd be fine for a migration period while the APIs are updated (then a lint would come along like unnecessary_try, but if there would be no plans to do so, then I fail to see the point.

Reprevise avatar Dec 09 '23 21:12 Reprevise

I don't see us changing existing APIs to return a Result<T> rather than T+throwing.

And I don't see us writing new APIs that way either, to be honest. Mainly because I don't actually expect us to add a language supported Result type.

It would be a massive change of direction to do that. Not necessarily a bad direction, if one had started going that way from the start, but a very large change to make at this time.

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. And then the function would still not be able to declare which exceptions it can throw, if it can throw more than one kind. If it could, it would effectively be Java's checked exceptions, just with, hopefully, nice syntax.

lrhn avatar Dec 10 '23 00:12 lrhn

Just adding a Result with the expected basic operations would be enough as a first step imo. It solves the specific problem of lots of incompatible Result types proliferating on pub.dev and the ? and ! operators and must-use lints solve ergonomics issues that cannot be addressed by the community.

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.

benthillerkus avatar Dec 10 '23 10:12 benthillerkus

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.

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.

benthillerkus avatar Dec 10 '23 10:12 benthillerkus

@lrhn

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. And then the function would still not be able to declare which exceptions it can throw, if it can throw now than one kind. If it could, it would effectively be Java's checked exceptions, just with, hopefully, nice syntax.

Again, to clarify, I'm not trying to introduce checked exceptions here. That's why I was making the point about E not needing to be throwable -- I'm fine if result! throws some other error that says "hey look you got a failure, here's the error value: null". So I disagree with

I think the goal of having a core Result class should be to remove the ambiguity of determining whether or not a method can "throw" an exception.

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.

I don't see us changing existing APIs to return a Result<T> rather than T+throwing.

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.

Levi-Lesches avatar Dec 10 '23 23:12 Levi-Lesches

We (dart) could adopt syntactical sugar for result/error of or {} block similar V lang. I could help make it less verbose.Something similar to https://github.com/vlang/v/blob/master/doc/docs.md#handling-optionsresults

sanathusk avatar Jan 06 '24 16:01 sanathusk

Syntactic sugar is imo not important at this point; just getting a good plain old dart class / type would already be very useful. And then, once the community has adopted them it's much easier to justify discussing syntactic sugar or other language features

benthillerkus avatar Jan 07 '24 01:01 benthillerkus

I'm not trying to introduce checked exceptions

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 ?

cedvdb avatar Jan 17 '24 03:01 cedvdb

Checked exceptions involve not being able to use throw unless you explicitly declare it, and being forced to handle all transitively thrown exceptions or declare those as well.

A Result type does not interfere with existing or future uses of throw at all. It is simply an alternative, one that many packages/programming styles already use (see the "Motivation" section of this proposal). The point of standardizing a Result class is not to enforce that the SDK use it instead of throwing, but rather to ensure that all future and current (after a little refactoring) Pub packages and private projects are compatible across different implementations (I'd also be curious if @munificent wants to scan some Pub packages to see just how many of these implementations are out there!)

So this would also allow throwing and returning results to coexist. Mostly within an API, eg, throwing Errors on totally invalid state and returning a result type when something predictable went wrong. Ideally, we could just magically rewrite history to get rid of most throws altogether, but since that's not possible, this proposal allows existing code to remain unchanged and is, therefore, way less breaking and way more feasible than checked exceptions, whether built-in or lint-based.

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.

Levi-Lesches avatar Jan 17 '24 03:01 Levi-Lesches

Checked exceptions involve not being able to use throw unless you explicitly declare it,

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.

Ideally, we could just magically rewrite history to get rid of most throws altogether

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)

cedvdb avatar Jan 17 '24 03:01 cedvdb

Couldn't dart infer the exceptions ? And just hint the user that not all exceptions are caught on the other end if not all are caught.

The question is where is the "other end"? Very few functions/methods in an app are called in main(), the rest are transitively spread through classes and packages and frameworks, so where should the warning come up? To avoid breaking existing code, all warnings would appear in main(), but to be useful, Dart would somehow need to know which errors should be handled by a package VS which are supposed to be caught by the caller. This can't really be automated without explicitly declaring them like checked exceptions do.

You also need to declare Result<S, E> as a return value too.

No because -- like you said -- there's no reason to rewrite all your throwing code in terms of Results. That's purely a style choice, and if you choose to migrate, you're making a breaking change. If Dart were to suddenly introduce warnings/errors for unhandled exceptions, most code today would be flagged as warnings and would thus be pretty breaking even when packages don't change versions at all.

Afaik, there is no consensus that this would be a good thing to begin with

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 Errors should be thrown and never handled programmatically. I just meant that this proposal prioritizes compatibility with the reality that most people today just throw.

Levi-Lesches avatar Jan 17 '24 03:01 Levi-Lesches

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 (Result<T, E>).

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 try doesn't really have an easy to implement alternative. In Rust lingo, ! can just be .unwrap(), and ?.something can just be .map((x) => x.something) (arguably a bit inconvenient).

Dart currently has no good way to represent an operation that can be expected to fail, besides using exceptions. For example, File.deleteSync returns void and the user is expected to know that the function is expected to throw. While I don't expect the standard library to adopt this feature, at least not right away, adding Result to Dart will allow packages to start adopting this in a standardised way.

dancojocaru2000 avatar Mar 12 '24 11:03 dancojocaru2000

You don't need classes to return "result or error". Here's my attempt at a zero-cost implementation using extension types. Sure, it doesn't force you to check for an error (you get a runtime exception if you forget), but there's much less ceremony around the issue. (This program is not guaranteed to be correct - dartpad misbehaves on it, so please take it as a strawman)

// 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
}

tatumizer avatar Mar 29 '24 15:03 tatumizer

Is a tuple any more zero cost than a class?

dancojocaru2000 avatar Mar 29 '24 16:03 dancojocaru2000

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. Also, all the micro-functions defined in the definition can be inlined, so it's as close to zero cost as it gets.

tatumizer avatar Mar 29 '24 16:03 tatumizer

@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 String! -- whatever gets the job done and solves the issues I laid out in the top comment, I will appreciate.

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:

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.

Levi-Lesches avatar Mar 29 '24 20:03 Levi-Lesches

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.

What is exactly the problem with this? Everyone uses what they like the most. Why would we need to have a standardized Result?

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 Result package, my code will still compile, unless they both export the Result classes themselves (which would be handleable by hide/show, but would be cumbersome nonetheless).

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 Result contravariantly, and, if possible, not use it in their public API at all.

mateusfccp avatar Mar 29 '24 20:03 mateusfccp

not use it in their public API at all

there are multiple packages on pub with exactly this

fragment the community even more

people are already using custom result types. standardizing one type would reduce fragmentation, not add to it.

massively breaking to change it now

this is not about removing anything; and exceptions ARE useful. this is about standardizing and simplifying the ecosystem.

What is exactly the problem with this?

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)

I just learned to live with exceptions

TLDR: good things aren't possible.

benthillerkus avatar Mar 29 '24 21:03 benthillerkus

there are multiple packages on pub with exactly this

There shouldn't be. We should strive to be consistent in using exceptions instead of building an inconsistent ecosystem that uses both.

people are already using custom result types. standardizing one type would reduce fragmentation, not add to it.

Sure, use it in your apps or internally in your packages. Having a first-class Result will encourage people to use it in their public APIs, making the ecosystem fragmented. If we all use exceptions, everything will work greatly.

this is not about removing anything; and exceptions ARE useful. this is about standardizing and simplifying the ecosystem.

This is the problem. Not removing exceptions and adding Result has zero advantage in Dart. They are not complementary, they are two different approaches to the same problem, i.e. error handling.

This is why most languages that were designed with builtin maybe monad doesn't have an exception system.

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)

This is why we shouldn't have Result in contravariant positions. Even better, if we all use exceptions, we will have no problem at all.

TLDR: good things aren't possible.

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 Result and keep the current exception system is simply not a "good thing".

mateusfccp avatar Mar 29 '24 21:03 mateusfccp

There shouldn't be. We should strive to be consistent in using exceptions instead of building an inconsistent ecosystem that uses 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 package:fpdart, and if I use their result type, I'm not influencing other packages that offer their own result types in any way. These authors and maintainers are separate from their downstream users, and there is no way to "force" everyone to migrate without breaking everything and choosing one superior option. By introducing result types as a first-class concept in Dart, everyone has a natural incentive to switch over, and no one has to worry about breaking their downstream users since the new types will be available for everyone.

Sure, use it in your apps or internally in your packages. Having a first-class Result will encourage people to use it in their public APIs, making the ecosystem fragmented. If we all use exceptions, everything will work greatly.

How is using a standard, first-class type "fragmenting" the ecosystem? Is using int instead of double problematic? How about returning sync vs future values? All types in the core SDK or first-party packages like dart:async are free for everyone to use, and choosing to use one, like Stream, isn't fragmentation, because it's available to everyone just as easily. On the other hand, if I want to use a result type, I wouldn't necessarily want to tie myself and my API to package:fpdart's implementation, nor any other 3rd-party version.

This is the problem. Not removing exceptions and adding Result has zero advantage in Dart. They are not complementary, they are two different approaches to the same problem, i.e. error handling.

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.

Levi-Lesches avatar Mar 29 '24 22:03 Levi-Lesches