Adding Result II: Unconstrained Boogaloo

Edit: I've created an updated proposal: https://github.com/apple/swift-evolution/pull/937#pullrequestreview-167116792

And an implementation PR: Add Result<Value, Error> by jshier · Pull Request #19982 · apple/swift · GitHub


Previously on Adding Result:

To summarize: Result is considered generally useful enough to be included in the standard library. However, in the past, strenuous disagreement between the typed and untyped-throws camps has prevented consensus on the exact form it should take.

To kick off another round of discussion, and before I update my proposal officially, I'd like to discuss the form that was brought up late in the last discussion:

enum Result<Value, Error> {
    case success(Value), failure(Error)
}

This spelling prevents many of the issues with both the untyped (lack of error typing) and typed (incompatibility with Swift's current error handling) versions, as well as allowing maximum flexibility for future error handling directions and integration with future async syntax. Yet it also allows integration with the current state of error handling in Swift and can provide the same features as the more constrained spellings. The most common objection to this form is that it isn't opinionated enough, and while I agree slightly in principle, I think the pragmatic gains from including Result in the language outweigh any philosophical angst I may feel about the failure state not being constrained to Error in some way.

Barring any startling revelations, and if there's general support, I'll update my proposal PR with this form and associated APIs. This includes support for initialization from a throwing closure, unwrapping into a throwing statement, convenience API for access the value and error, and functional transform functions.

19 Likes

This seems like the right direction to me. If / when we have support for default generic arguments this could become enum Result<Value, Error = Swift.Error>. I think this design (with a default generic argument) would suit everyone reasonably well and would therefore avoid the risk of people continuing to use third party Result types.

12 Likes

I also wanted to add that I'd be glad to do the implementation PR, if someone can tell me where such a type would go. Otherwise I'd probably just put it next to Optional.

Additionally, I wanted to point out that Kotlin very recently added Result<T>. They added a limitation that functions can't return it, but that's mainly to maintain the integrity of their exception model. They also have a good summary of Result-like types in other languages:

Kotlin: Result<T>
Scala: Try[T]
Rust: Result<T, E>
Haskell: Exceptional e t
C++ (proposed): expected<E, T>

So we can see that, of the languages with a type like this, only Kotlin and Scala do not genericize the error type, and those that do do not constrain that type to some particular error representation.

1 Like

As Result is a new type, it should go in its own file, as stdlib/public/core/Result.swift.

I have also been thinking about Result, and my primary belief is that Result should both be as similar to Optional and extend as much of Optional's sugar as is reasonably unambiguous, as well as having much 'swift-error-model' specific sugar as possible.

In particular, I think the preferred way to make a Result should be by replacing the try in a throwing expression with "catch":

func throwing() throws -> T { /*...*/ }
let result: Result<T, Swift.Error> = catch throwing()

Also, I think that by symmetry with Optional.some the value case should also be called some, and its type should be called Wrapped.

enum Result<Wrapped, Failure> {
  case some(Wrapped)
  case error(Failure)
}

Finally, some sugar currently on Optional I think should certainly be extended to Result:

  • Any value of type T should be implicitly convertible to the .some(T) case of the Result type, as with T?.
  • The value? binding pattern (e.g. here) should also match the .some(T) case of the Result type, in addition to that of T?.
  • The postfix ! reserved operator should also be useable on a Result value, to assert it is in the .some case, and unwrap it if so.
3 Likes

You can count me into the camp of typed throwing system, which should be unconstrained in terms of a type it can throw but limited to a single type and completely opt-in. However it does not mean that I'm against adding Result to stdlib. In fact I'm actually in favor of both.

That said, I really like the unconstrained generic Error type parameter of the proposed Result type. The one thing that I would like to see though is an agnostic transformation API to the current and future throwing system. If we ever would add opt-in typed throws to Swift then I'd wish for convenient transformation from a typed throwing function into Result and vice versa.

// Currently
() throws -> Value              [transforms to]   Result<Value, Error>
Result<Value, Something>        [transforms to]   () throws -> Value

// In the future
() throws(Something) -> Value   [transforms to]   Result<Value, Something>
Result<Value, Something>        [transforms to]   () throws(Something) -> Value

Maybe a little off-topic, but can someone answer me the following questions:

  • Why do we need to conform our error types to Swift.Error (only for Obj-C intercompatibility)?
  • Typed throws do not need this limitation, is that correct?
2 Likes

My very personal answer to those two is "yes" ;-) - there are way to much error-protocols...

Not sure I see the value in continuing to pursue the addition of the Result type in the stdlib. Clearly the core team didn't see value in incorporating this as an alternative option for error management, so what specifically is different about this revived proposal that will likely overturn that decision.

Also wouldn't a more appropriate time to address the need for this be in conjunction with Concurrency in Swift?


I personally would have loved to see a more formal adoption of FP types in Swift, with flexibility to opt in or not; similar to scalaz. But hey we are where we are; those who desired functional types have long since rolled their own.

If you read the previous discussion, there was support from several core team members for adding Result, it's just that the most popular form wasn't the one suggested, and so the previous proposal didn't move beyond the pitch stage. As I said, this different spelling should allow greater support and flexibility.

Additionally, while Result may be part of the concurrency story for Swift (like it was for Kotlin), it's a generally useful type outside of those scenarios and so stands alone as a manual error propagation mechanism.

Finally, Result is not a "functional" type. While it has applicability and a place in FP, it is not derived from FP nor is FP required to use it.

3 Likes

I did; granted I probably missed a bit; however I did get the distinct impression that not everyone was sold on the idea.

Sure, but it does share some similarities with Either, but I agree it's certainly not as comprehensive, and hence I doubt I'd have much use for it outside of concurrency; assuming that's still a core team consideration.

Maybe I'm completely in the minority here, but I don't really like this implementation of a Result type, for several reasons:

  • It seems like it is providing way too much convenience functionality. I view Result as very similar to Optional, in that both are just wrappers around a value (or an error in Result's case), and nothing more. But this offers isSuccess, isFailure, withValue, withError, ifSuccess, ifFailure, which I think would be rather strange if added to optionals (such as isSome, isNone, withSome, ifSome, ifNone).

  • I'm quite sure I'm in the minority on this one, but I don't like that there are convenience accessors for the value/error. I think the only way to access the value should be via the unwrap() method (which IMO should be named just value(). That way, you're still ultimately using Swift's native error handling (since you'd need to do/try/catch to retrieve the value), it's just later in the pipeline. Plus, then the similarities with Optional continue: you unwrap an optional value with if let, and you unwrap a result value with try.

  • Of course, to use Swift's native error handling, the error type needs to conform to Swift.Error. I would want the type signature to be Result<T, U: Swift.Error> except then you couldn't create Result<T, Swift.Error>, which is very frustrating with Swift's untyped error handling. So then I vote strongly for just having Result<T> where the error type is just Swift.Error.

FWIW, here's the complete implementation of Result I've been using for my projects:

public enum Result<ValueType>
{
    case success(ValueType)
    case failure(Error)
    
    public func value() throws -> ValueType
    {
        switch self
        {
        case .success(let value): return value
        case .failure(let error): throw error
        }
    }
    
    public func verify() throws
    {
        switch self
        {
        case .success: break
        case .failure(let error): throw error
        }
    }
}

public extension Result where ValueType == Void
{
    static var success: Result {
        return .success(())
    }
}

Overall though, I very much want Result to be added to the standard library, so thanks for pushing this forward! :smiley:

6 Likes

Yes, the proposal PR is just there for historical purposes. Once I've created a PR with the base Result type I'll also create an updated proposal PR for it. It won't have the same convenience API, at least initially. I'm tempted to leave most of it in, and if the proposal gets to review, let the reviewers trim out anything they feel is too much for the initial proposal.

Personally, I do think Optional should have an try-able unwrap() function, as well as a withValue accessor, as otherwise we have to abuse map.

Yeah, not having to use try / catch is a huge benefit to having Result. As I describe in the proposal, it's the manually propagating counterpart to try / catch, and would fail to be so if you had to try / catch it on the other side.

Yeah, that was my initial proposal, but such a type is incompatible with any possible typed-throws or throw non-Error-conforming type futures. So this version is a compromise that was very popular in the March section of the discussion I linked.

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