Adding Result II: Unconstrained Boogaloo

I would strongly prefer the Result<Value> form with Swift.Error as the error type, with added ways to optionally pull a typed error out of it when desired. e.g. func error<E>()->E? instead of var error:Error?.

In my experience, the typed error tends to get in the way of compose-ability, especially between types that aren't supposed to know about each other. Even with Swift.Error being the default generic type, it would stop it from composing with functions that handle the same base type, but don't know the custom errors that something might return... though I guess you could have a map-style function to genericize the error when it conforms to Swift.Error.

I agree with @riley that unwrap() should be called value() throws -> T. I call what you are calling value toOptional()->T?, which I believe is clearer. You could also call it something like optionalValue, but I believe that it should be made clearer that you are turning it into an optional.

@Dante-Broggi had an interesting idea of extending optional syntax here that is worth thinking about. So myResult! would unwrap or trap in the case of error, and perhaps myResult? could unwrap to an optional? This would be especially nice with optional chaining: myResult?.methodOnT.

One useful init form that I have on my Result type that I saw missing here is:

init(_ value:T?, nilError:@autoclosure ()->Swift.Error) {
        guard let value = value else {
            self = .error(nilError())
            return
        }
        self = .success(value)
    }

This allows you to easily turn an optional returning function into a result returning one.

Also missing are these two sugar inits:

init(_ value:T) {
    self = .success(value)
}
    
init(error:Swift.Error) {
    self = .error(error)
}

This lets you write Result(2) instead of Result.success(2), which is much more readable IMO. Though, I suppose instead of the first one, you could just make your public init(value: () throws -> Value) into an autoclosure: public init(_ value: @autoclosure () throws -> Value). Then you can do both Result(2) and Result(try myThrowingFunction()).

On the issue of typed throws, I think it's worth reading what Java developers say about checked exceptions: java - The case against checked exceptions - Stack Overflow

Of course, we wouldn't do typed-throws exactly like Java's exceptions, but some of the points are still applicable. I think the 2nd reply (from bobince) summarises my concerns well-enough:

  • unnecessarily increases coupling;
  • makes interface signatures very brittle to change;
  • makes the code less readable;

Errors in Swift can be anything (not just enums - classes and structs, too). However, they typically are enums. I can see this easily becoming a situation where every function has its own error enum, lots of which are wrapping other error enums from other functions - which is lots of boilerplate and leads to very tight coupling of code.

I completely understand why people want to have "exhaustive" error handling - for basically the same reason that we all enjoy exhaustive enum switching. But I think the type-system is not the right place for functions to expose and handle errors.


On the issue of Result<T> - I read the Kotlin proposal. Their motivation seems to mostly revolve around asynchronous code. Language-level concurrency support is on the roadmap, and I think we should wait until that time before considering such a type. Maybe we will indeed go that route in the end; but maybe we won't.

3 Likes

I think any typed-throws feature would almost certainly still require the error type to conform to Error. Not doing this would create an enormous source of unnecessary complexity in terms of interoperating with untyped-throws.

14 Likes

That's actually a very good point. I did not thought about it before you mentioned it. But from the other perspective what does Error really gives us developers? The protocol has no requirements and any custom type can conform to it. Maybe it would make sense to synthesise the conformance to the Error protocol on the type you throw (similar to how some enums are automatically conforming to Hashable) or completely drop / deprecate the protocol and let the compiler do the heavy lifting here? Are there any technical boundaries that are still valid to this date that make that protocol required in the stdlib?

1 Like

There are technical reasons for it, yes. But it also catches silly bugs like attempting to throw a string when you meant to wrap that string in a particular case, encourages programmers to use well-defined error types instead of ad-hoc values, and allows generic algorithms to be defined on all error types via protocol extensions, while being a very minor burden on declaring an error type.

6 Likes

+1 to an unconstrained, two-argument Result type. Referring back to @John_McCall's original error handling rationale, I think throws and Result can be seen as addressing different corners of the design space. As the rationale describes, throws was designed for systemic errors, which tend to have a large distance between error source and error handler, so source and handler end up needing to be rather loosely coupled, which was a motivator for the exception-like propagation and lack of specific typing in the current throws feature. Result on the other hand is great for conditions that are intended to be immediately handled by the caller, for which being an regular return value with a more specific failure type is in fact the more convenient representation. While we should definitely have helper operations to map back and forth between throws and Result when the failure type of Result conforms to Error, I think Result would be maximally useful if it doesn't need to be used as an integrated facet of the throws-based error handling mechanism.

21 Likes

As someone in the "typed throws is the right thing to do" camp, I agree with John: the compiler should require the thrown type to conform to Error. That said, we don't necessarily have to require Result to have the error type be constrained the same way. These seem like independent decisions and I can see arguments both ways.

4 Likes

I've created a new proposal PR and an implementation PR. Comments welcome!

I did find my implementation of unwrap() (bikeshedding welcome) to be a bit awkward, as I need to provide implementations in extensions of both where Error: Swift.Error and where Error == Swift.Error, which leads to some duplication. Suggestions would be welcome.

1 Like

Unconstrained vs constrained could be resolved with Either<A, B> and Result as a type alias for Either<A, Error>. I am mostly writing this to be sure that it is mentioned but I really think that this would be the best way to go.

6 Likes

Either really has no buy in by the Swift community and so doesn't hold its own weight as a standard type. There's little point to adding it if 99.9% of usage would be as Result.

I can add it as an Alternative to the proposal if you really think it's necessary.

1 Like

I am not terribly interested in debating how much of 'no buy in' is the result of it not being easily available beyond stating that I use Either and that modeling Result in the described way solves the issue of "Unconstrained vs constrained" in a clear manner that has prior art.

7 Likes

I've added Either to the Alternatives section.

I'm typically in favor of having different types even if they have the same "shape" because type names also convey semantic information, so a user reading the code would see Result and understand that it's success vs. failure and see Either and understand that it's just one of two things.

But Either as a sum of two types feels kind of weak right now. What would be really nice is when we have variadic generics, supporting an Either<T...> as a generic building block. That's still not quite as simple (?) as it sounds though, because for the best ergonomics you'd want to synthesize a case for each type argument and it's not immediately clear how that would work.

9 Likes

While what you've described would be useful, I think it is completely unrelated to this except that it can be viewed–in a specific way–as a generalization of Either. I say unrelated because the completely finite scope of Either is part of the type. One of two types is in the box. The end. That can be explained with almost no effort and without technical terms. Variadics turn the type you're talking about into something close to a tuple. It probably should be as lightweight to utter as a tuple and is incredibly ad hoc as an idea.

Either can be an immensely useful type because, at its heart, it is just a Bool that can carry a bit of something along with 'yes' or 'no'. Proof of this is right there in Optional. I've lobbied for enum protocols to try to capture some of this and allow a basic shape to be represented with 'renamed' cases so that we can share useful functionality. Short of that, making Result a subtype of Either is my preference.

5 Likes

But what I described isn't a tuple at all; a tuple doesn't satisfy the invariant that only one of the values may be set. Variadic Either is still very much a finite type—you can't specialize it with an infinite number of types arguments, so while different specializations can have different numbers of types, each is still very much finite.

If Either<A, B> "can be explained with almost no effort and without technical terms", I don't see how it's a stretch at all to extend that to Either<A, B, C> or more. What else needs to be said than "Either<A, B> can hold a value of either A or B" and "Either<A, B, C> can hold a value of either A, or B, or C"? While the word "either" typically references "one of two things", using it to describe one of more than two things is also perfectly grammatical English.

Isn't your position here regarding Either in opposition to the one taken in the SetAlgebra thread where you said the far more obscure terms "intensional" and "extensional" are fine because "we need to give developers more credit than this" and "[y]ou are able to learn these terms"?

Either has been addressed in the proposal, so further discussion of it seems off topic for this thread. It could make an interesting separate discussion though, so feel free to start a new thread.

I don't quite get how this be both valuable or how you'd ensure the internal conformance with FP-esque algebras.

Plus why wouldn't Either<L, R> where R was an Enum with associated values suffice? Anyway as requested this line of discussion probably deserves a new thread.

While keeping the convenience APIs minimal, I think a map and flatMap implementation should be part of the base type, similar to Optional. On Alamofire's Result, map takes the value and returns Result, and there's a separate mapError to access the error value. antitypical/Result does the same thing (in addition to a ton of other convenience API), but I'm not sure if that's the appropriate signature. Opinions?

2 Likes

func map<NewValue>(_ transform: (Value) -> NewValue) -> Result<NewValue, Error>
func mapError<NewError>(_ transform: (Error) -> NewError) -> Result<Value, NewError>
func flatMap<NewValue>(_ transform: (Value) -> Result<NewValue, Error>) -> Result<NewValue, Error>

We could also have:

extension Result where Error == Swift.Error {
    func flatMap<NewValue>(_ transform: (Value) throws -> NewValue) -> Result<NewValue, Error>
}

Would we want to throw / rethrow as well, like Optional.map(_:)?
e.g. func map<NewValue>(_ transform: (Value) throws -> NewValue) rethrows -> Result<NewValue, Error>
Or would we want to swallow such error into another Result, as your extension seems to imply?