Pre-SIP: Proposal of introducing a Rust-like type "Result"

IMO If a new data type will be introduced, it should be introduced outside the standard library, and should fight for its own mindshare separate from Either. Library and framework authors could easily adopt it, and over time if it becomes the de-facto standard it could be considered for inclusion in the standard library.

This way, when the final decision point comes, we can discuss a small number of known quantities, rather than a thousand different ideas limited only by people’s imagination.

This is not an unusual process. scala.concurrent.Future was basically incubated in akka/twitter-util, and there have been discussions for upstreaming com.lihaoyi:sourcecode due to its fundamental nature and widespread use.

If we didn’t get Either “right” after a tremendous amount of consultation and consensus, I don’t have any confidence a “fixed” result type would do any better, unless we have this incubation process to let it bake see if it truly meets the needs of the community. Any technical details of what kind of result type would be better are secondary to making sure we get the process right.

12 Likes

Either.cond is exactly how I would do it. Considering you would typically use it

Either.cond(weCanUseDesiredVAlue, desiredValue, fallbackValue)

it is just a convenience for

if(weCanUseDesiredValue) Right(desiredValue) else Left(fallbackValue)

I get it, you want the “Left” value to the left and the “Right” value to the right. But is it really important? No one will get this wrong by accident, because it won’t type check. And the “Right” value isn’t really on the left, it’s in the middle.

If the students ask, just say “historical reasons”. If they make rude jokes about it, laugh with them.

It sounds very scary if advancing a major version is seen as a compelling reason to remove not only very wart, but also every wrinkle and unwanted hair. It’s tempting to try to make everything perfect, but only time will tell what is best. Migration is going to be hell anyway, please make it not unnecessarily difficult.

Keep Either as it is and add an alternative. With trivial conversion between Either and alternative. If people really prefer the alternative, they will switch over gradually.

3 Likes

It would not be the exact same semantics because Either/Left/Right does not have any semantics to it.

I really hope that this is not a valid reason to swipe an idea from the table.

1 Like

It would not be the exact same semantics because Either / Left / Right does not have any semantics to it.

It does, that’s why map maps on the right side and not on the left.

I really hope that this is not a valid reason to swipe an idea from the table.

You got me there. It was just my emotional response to an idea I don’t really agree with. Sorry.

2 Likes

Using Either for error handling is one of the worst cases of Haskellisms that got carried into Scala. Honestly Either should represent unions and if you want to represent errors you should be using Try instead. This also makes the intention in code much clearer (believe it or not I have spent a surprisingly large amount of time having to explain this to new people).

Right biased map should be present on Try, but for Either we should have leftMap and rightMap (or the equivalent).

But we are here now and we just copied something from another language without evaluating whether it made general sense or not and for historical (or other) reasons we just transformed Either into something is meant to be Try

My suggestion would be to add methods to scala.util.Try so that it has the same capabilities as current Either, and just treat Either as the union type which it actually originally was meant to be. We can then also add things like Applicative to Try (so that we can accumulate errors) which actually makes sense in the context of Try

2 Likes

What? Try is a very inflexible error-handling mechanism: your errors must be Throwables, you can’t restrict what kind of Throwable are errors, and you can’t even guarantee that you only get the errors you expect, because all of the combinators catch! I’m not saying that Try is bad, but it’s nothing close to a replacement for Either as it’s used in FP.

What’s the benefit? All you’re doing is increasing the amount of typing and making it not work with for-comprehensions.

This assumes that we have Applicative in stdlib, which I’m not opposed to, but if it’s a monad it can’t be an “accumulating” applicative. More fundamentally, how do you combine Throwables?

3 Likes

If something is an error it should be reflected in its type, thats why we use strongly typed languages. It should be standard practice that if you have some type that is an error that it should be indicated as such (often by implementing some sealed trait ADT that represents an error). Otherwise its not an error and then you should be using Either. Of course there is nothing wrong with having helper functions to convert an Either to a Try if you have something that wasn’t an error at one point that is now an error.

Also I am not talking about throwing exceptions here, I am talking about treating errors of values and such errors should be marked as such.

And yes Try is inflexible because all of the usability improvements happened to Either in Scala 2.12, remember left and right projections on Either?

We use Either for actual unions, and in this case the standard .map case is incredibly confusing.

The fact that Try’s left type is a Throwable is a diversion here. The left value of Try could be some other type, even just a market Trait that indicates its holding an error.

The main point I am making is distinguishing between representing a union and representing an error from a computation, because those two are very different things.

Even the documentation of Try which was written ages ago was meant to indicate this, i.e.

/**
 * The `Try` type represents a computation that may either result in an exception, or return a
 * successfully computed value. It's similar to, but semantically different from the [[scala.util.Either]] type.
 *
 * Instances of `Try[T]`, are either an instance of [[scala.util.Success]][T] or [[scala.util.Failure]][T].
 *
 * For example, `Try` can be used to perform division on a user-defined input, without the need to do explicit
 * exception-handling in all of the places that an exception might occur.

Try was meant to be either our monad or applicative for dealing with errors. Its definitely an annoyance that its been coupled to Throwable, but that is something that could be rectified (Scala could implement its own error type which could be a Throwable or just a standard error which would be on the left of Try). Either that or people just had a really big aversion to entending Throwable.

I strongly agree with this post from @odersky . I would also be happy with introducing a Result type which is similar to the current Either but better indicates that its only meant to be for errors.

1 Like

I have nothing against (or for) a Result class. I’d argue that a library like cats or any development team can, could, and should develop their own structures if the current ones don’t fit. Try/Success/Fail are a reasonable answer to Either. On the other hand, adding one or a few simple classes isn’t a huge deal.

A few things about Fail. If one is going to create an disciplined error framework, why not base it on one that exists? Throwable meets every need I’ve ever seen.

It does allow accumulating errors. All Throwable have an optional “cause.” They were intended to handle cascading errors from the first release of Java. Each throwable can fill its stack-trace. They can handle errors created by multiple and separate threads.

Throwable are designed to be specialized. IOException is the root of every (non-runtime) exception caused by I/O. Any and all libraries can create their own error definition structure. Because Throwable already supports cascading errors, a private library can always handle errors from other libraries.

With traits, one can add any kind of type graph one needs. Both Java and scala can match on any subclass of Throwable or trait/interface. It’s not a good idea to have a complicated error infrastructure. The current Throwable hierarchy allows anything that is needed. Including type parameters. If the error is caused by a single object, or a complex collection of objects, a library author can always add instance variables to the Throwable. Any type declaration can be used.

Throwables need not be thrown. That’s the whole purpose behind Try. It catches things that are throw outside to control of the current library and returns it without unwinding its context. It allows halting the computational chain, poisoning it, or even recovering it.

For implementation convenience, it would be nice if Option, Success, Fail, Nothing were all rolled up into one thing. From a modeling perspective, not so much so. I suspect the current problem is from version skew. If there was a AnyRef like trait that indicated the null set, they could all fall into the same type hierarchy. But they were all defined at different times, for different reasons, and its too late now. Without putting them all on the deprecated list at least.

If any library wants to do that, it certainly can. A NothingHere[T] trait, a SomethingHere[T] trait, an InvalidComputation[E<:Throwable] trait. The first two should be IterableOnce[T] to handle the same semantics as Option and Success. The later two would be used for pattern matching. It would even be possible to have a NothingHere[T] and InvalidComputation mix-in. I’m not sure that last one would be a good idea. Some mix of toOption, toSuccess, toFail may be in order for interface boundaries.

I’m indifferent to the change. Which probably means I don’t think it’s worth the time. If a unified framework for these concepts are created, Option, Try, Success, Fail should be deprecated. Probably never removed from the language, but to encourage people to use the new unified framework.

To be clear, I think the problem most folks have with Try is that its failure type is fixed at all, not that it’s fixed to Throwable.

2 Likes

This is my whole point though, if have you have errors they should be reflected in their type.

The problem with a fixed failure type is that it forces you to unsafely downcast if you want to access anything other than the members that are defined on that fixed type. This is either unsafe or underpowered, depending on how strongly you try to avoid unsafe casts.

Reflecting errors in the type doesn’t require paying the annoyance tax of restricting yourself to error types which extend Exception1, and Either can be used to convey this with far less fuss than Try:

type E // stand-in for whatever you use for errors
type Errors = NonEmptyList[E]

def foo[A]: Either[Errors, A]

  1. Specifically: Exception subclasses are annoying to compose, tempt junior devs to use them for flow control (see: Integer.parseValue), often provide no more information than a String, and are a right pain in the neck to test because they don’t provide sensible equality.
3 Likes

What does it mean to reflect an error int the type? Do you want a standard type to cover all errors, or do you want every one to use any type as long as it has “Error” in the name, or what are you asking for?

Okay to make things more clear, let me state this in a different way. If you are representing errors with basic types as String, i.e. Either[String, T] for some value T this is considered code smell. I have just gone through all of our backend applications at work and we never do this, and there are multiple reasons why.

  • You can’t match on the string to catch a specific error (well technically you can but you would have to match on the contents of the string at which point you should just represent the error as a seperate type)
  • Due to needing to compose errors of different types from different clients you invariable end up using some error ADT type, i.e. if you have something like this
    for {
      user   <- getUser(someUser)
      tweet  <- getTweetFromUser(tweet)
    } yield tweet
    
    And getUser returns values of type Either[UserNotFoundError, Unit] and getTweetFromUser returns values of types Either[TweetNotFound, Unit] then you need some sane way to compose these values, and the only real practical way to do this is to have some type that marks that both UserNotFoundError and TweetNotFound are errors. The point is that Throwable can be this market type to indicate an error (there is an argument that something else should be used, this is explained later). Note that you can still used sealed trait or sealed abstract class to make sure at compile time that a certain set of errors has exhaustivity checking, i.e.
    sealed abstract class UserErrors(errorMessage: String) extends GeneralError
    
    object UserErrors {
      case object UserNotFoundError extends UserErrors("User not found")
    }
    
    This way when you match against UserErrors you will know at compile time that you handle all cases. In fact in our backend code we have type Result[T] = Either[GeneralError, T] to represent this and GeneralError is defined as
    abstract class GeneralError extends Exception
    

So the point here is, I understand the argument that Try had issues, i.e. its hard to use Throwable in a general manner (because issue here is that Throwable is not an interface but rather a class so it has issues with mixin composition) but the solution wasn’t to suddenly turn Either into an some weird error type. One of the points of using such abstractions is that we want to use the most restrictive abstraction for the job, and that is not Either.

So to summarize in tl;dr format

  • Almost never in code do I represent errors as String's. It is too general and doesn’t compose. You always end up making either a case classor a case object which encapsulates all of the details of the error so you can then match properly on the error
  • Try was supposed to be the original error type but probably set things up incorrectly by fixing the type to Throwable rather than some other type of Trait
  • Try should support the right biased map/flatMap (since its not meant ot represent true unions) with methods like recover in case you want to move the left side into the result. The point of this is code clarity and conveying intent properly
  • You can also make an Appilcative error types for accumulating errors which uses the same fixed type that Try (or Result or w/e type we happen to use)
  • You should always use the most restrictive abstraction, this is not Either for handling errors. Either was always used to represent tagged unions in a general way, even the docs reflected this, i.e.
    /** Represents a value of one of two possible types (a disjoint union.)
    *  An instance of `Either` is an instance of either [[scala.util.Left]] or [[scala.util.Right]].
    

Also I understand that with regards to composition of errors that Dotty should help here with union types however its not a full solution to the problems presented because

  • You can’t use union types to compose Either[String, Unit] to another error Either[String, Unit] so you end up having to type your errors anyways
  • Dotty can represent the previous example using UserNotFoundError | TweetNotFoundError however in our typical applications we have not 2 of these errors, but something greater than a hundred errors. Having to manually type these would be a massive PITA.
1 Like

I also agree with the typed errors approach. IMHO it’s not the Either in Either[X, Y] or Result in Result[A, B] which make it a result-like thing. It’s the X or A. For example, if we have

Either[CustomerOrder, SuplierOrder]

it’s clear this is one of 2 kinds of orders. On the other hand, if we have

Either[ProcessingError, SuplierOrder]

(especially, if we have ProcessingError <: Exception), then it’s also clear this is some kind of a result of a fallible process. That makes me thing there is no problem with Either and that it works just fine for working with errors/results.
(There’s cats.data.ValidatedNec if we want to accumulate errors instead of failing fast.)

1 Like

I’m not sure I understand your argument here @mdedetrich. Nobody in the thread previously has mentioned using String as the error type for Either and the rest of your post implies that the best strategy you found was to use Either with a sealed trait of error values, which is exactly how Either is used by the rest of the community now.

As far as I can tell the problem with Try are far more significant than the problems with Either for functional error handling and it would break tons of existing code to change Try to be suitable.

2 Likes

Sorry if I missed it, but what is that abstraction? Personally I would expect the “most restrictive” way to represent a possible error to have at least these restrictions:

  • it can’t contain any other errors than those I expect
  • it can’t contain any other results than those I expect
  • it can’t contain something that’s neither an error nor a result, nor something which is both

Either satisfies all three of these requirements, as does scalactic.Or (which is isomorphic but with the roles of the type parameters swapped).

Is there a further restriction you’d like to have that would make Either no longer restrictive enough for you?

[citation needed]? Try was originally added in 2012 (2.9, I believe) in commit 7d206f3, with the acknowledgement that it was inspired by Twitter’s work. That’s hardly “original” in either sense of the word.

3 Likes

Several months later I wonder: What would be next steps? Is there a point to file a SIP?

Yes, it should have have a specific error type representing Left which Either doesn’t have. Otherwise you cant distinguish between something that is an error vs something that is just representing a union.

I didn’t mean original in the sense that it was introduced by Scala, but original in the sense that it was the first type that was introduced which was meant to deal specifically with errors.

Sorry for taking so long to respond, completely forgot about the topic.

I’m looking over this old thread because the site decided to show it to me at the top, but it’s funny because Rust’s Result is just an Either. It has no restriction on the type of Err, it does not accumulate, it has a “right” bias. There’s only two significant differences:

  • Result, Ok and Err clearly convey intent; and
  • ? as a shortcut for match { case ok @ Right(_) => ok case err @ Left(_) => return err } turns all definitions with return type Result into seamless Right-biased Either for comprehensions.

Years of use and multiple major releases of Scalaz and the for-all-purposes fork as Cats with the third major release almost out have not produced anything other than small syntactical helpers to Either (.asRight[A] and .asLeft[B] are really nice), Validated, and MonadError (which is essentially a Try without a fixed error type). Aside, I suppose, from ZIO, but what makes that one different is that it conflates multiple responsibilities.

And that’s pretty much all of this discussion: the problems with Either are the names used and lack of syntactic support for less cumbersome expressions; a result type with error accumulation would be useful; Try would be much more useful if it wasn’t tied to Exception.

As a side note, it would be really nice if there was short syntactic construct for early return of error/extraction of success on Try and Either-returning definitions. Something like a .getOrReturn() macro on both that expanded into a match with early return.

And, digressing, Rust’s exception handling is migraine-inducing. You think Either in Scala is bad? How about dozens of exception-handling libraries because there’s none in the standard library, and the most popular ones have been abandoned for a while?