Adding Result II: Unconstrained Boogaloo

Wouldn't bimap take a Result and return a Result?

Maybe it should be called mapValues<U>(_ transform: (Value)throws->U) -> Result<U,Error> to emphasize that the transform is only called when the result has a value. And, yes, it would have to be constrained to when Error = Swift.Error.

Actually, that is why I am opposed to this form of Result overall. I am not really seeing the benefits of being able to define the error type... especially when it makes interop with Swift's error handling so much more difficult. One of the main use-cases for Result (for me at least) is to do a series of actions which could possibly fail (usually using Swift's error handling) and ending up with a Result containing either the end value or the first error (with it short-circuiting once an error is reached).

Without the ability to handle throwing functions, Result is basically useless to me, and I am a strong -1.

What I would much rather see is Result<Value> with some convenience methods for handling errors of a specific type. For example:

func onError<E:Swift.Error>(ofType type:E, handler:@escaping (E)->() ) -> Self {...}

than you can even handle different types of errors and still get completion in your switch statements:

myResult.onError(ofType: MyError.self) {
    switch $0 {
    case //We get an exhaustive switch here
    }
}.onError(ofType: MyOtherError.self) {
    switch $0 {
    case //We get an exhaustive switch here too
    }
}

Why would you lose the previous error? Shouldn't the transform only be called if you have a value?

If you are talking about mapError, then isn't losing/transforming the error the point there?

Yes, sorry I was incorrect here. The function you describe is basically a flatMap in a constrained extension where Error == Swift.Error. It's shuffling around syntax but has the semantics of flatMap.

To correct my own mistake, blimap would look like this:

func bimap<NewValue, NewError>(
    errorTransform: (Error) -> NewError, 
    valueTransform: (Value) -> NewValue
) -> Result<NewValue, NewError> {
    switch self {
        case .success(let value): return .value(valueTransform(value))
        case .failure(let error): return .value(errorTransform(error))
    }
}

The point of bimap is to separate the transforms. The above example puts the error transform first under the assumption that bimap would often be useful in a context where you have a small number of error transforms and may have a single-use value transform. This ordering allows trailing closure syntax to be used for the value transform.

It does not make interop more difficult in the case where Error == Swift.Error. Further, if Swift receives typed errors in the future (which sounds reasonably likely) a design that does not allow customizing the error type would not be able to interoperate with Swift's error handling at all. In fact, we would need to update Result to use a design like the pitched one anyway, either incurring a breaking change or a default Error argument of Swift.Error (which is obviously not possible right now).

What it does do is enable use cases that are not well supported by Swift.Error. For example, IIRC @Chris_Lattner3 mentioned some systems use cases where the abstraction of an existential introduces problematic performance overhead.

We can handle throwing functions where it makes sense. This is exactly where Error == Swift.Error and the semantics of the operation are that a new error may be produced. I am only arguing against supporting throwing functions where it introduces a second error pathway. In other cases, I am arguing that the operation should be named correctly such that the ability to produce a new error is reflected. Thus the operation you call map should actually be called flatMap for example.

I think it is generally an antipattern to both throw and return a Result. I certainly do not want to see API in the standard library do this.

This is what I am arguing for, with the additional constraint that the transform itself not return Result<T, Swift.Error> or Swift.Error. There should only be one pathway to report a new error.

You don't "lose" the previous error any more than you do with any other design. Any method that produces a new Result.failure is going to discard information about what was in the previous value (regardless of whether it was a value or an error) unless that information is somehow stored in the error that was produced.

I don't think it inherently means confusion. Only when introduces a second error pathway out of the transform or if the name of the method is not aligned with its semantics.

2 Likes

Agreed, but I'd even be more blunt and say the entire purpose of Result (with similarity to Either) is to avoid throwing exceptions, and instead embracing Result's monadic binding.

Surely its more a case of holding onto the first error, subsequent / dependant processes simply would not execute, and just relay the 1st error?

I guess it does kind of have the semantics of flatMap in a way. I have been defining flatMap like this:

    func flatMapValue<U>(_ transform: (T)->Result<U> ) -> Result<U> {
        switch self {
        case .error(let error): return Result<U>(error: error)
        case .success(let value): return transform(value)
        }
    }

But I suppose we could have two different signatures for flatMap. Or we could just call it then as several Result implementations do...

In my experience, allowing custom error values for Results can make interop between various frameworks difficult as well, since they don't know about each other's error types. It seems that at the very least we will need something that lets us normalize error types when they are a subtype of Swift.Error:

extension Result where Error:Swift.Error {
    func withSwiftError()->Result<Value,Swift.Error> {
        switch self {
        case .success(let value): return Result<Value,Swift.Error>(value)
        case .error(let e): return Result<Value,Swift.Error>(error: e)
        }
    }
}

Are there strong use-cases which make all this trouble worth it? Couldn't systems with extreme overhead needs just make their own Result type?

I think that is one very large use case, but I also use it fairly often to defer throwing until I am in the context I want to potentially throw in (this is especially true with something like a promise or future). It is really nice to be able to move back and forth between the two worlds seamlessly.

1 Like

Sure it can. But nothing is requiring frameworks to expose concrete error types either just because it is possible.

This looks like a very reasonable API. It can be trivially composed with mapError { $0 as Swift.Error } (or even just mapError { $0 } when the context fills in Swift.Error). Despite being trivially compassable, I can see an argument that Swift.Error plays an important enough role to justify a method. Many people are likely to write such a method and it might be a good idea to standardize on a name (for example, I would not use withSwiftError).

Would you be opposed to calling this something like TypedResult (or whatever) instead, and then defining Result as below?

typealias Result<T> = TypedResult<T,Swift.Error>

That way Result<Value> can be the default, but those who need a custom error type can do so fairly easily as well...

1 Like

So basically hinged off this:

  public init(_ throwing: () throws -> Value) {
    do {
      let value = try throwing()
      self = .success(value)
    } catch {
      self = .failure(error)
    }
  }

also this:

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

and:

func value()throws -> T {
        switch self {
        case .success(let val): return val
        case .error(let error): throw error
        }
    }

Error's special handling in the implementation and lack of exposed requirements would make it easy to "self-conform" the type to the protocol, as a stopgap to supporting this more generally.

3 Likes

We can also add requirements to protocols resiliently, and it may make sense to add requirements to Error in the future. For instance, maybe we'd want to add an API to capture callstack information in a thrown error in the future or something like that.

5 Likes

This is not included in this proposal?

proposal has this as unwrapped()

Yeah, both clearly a different approach to what I'd favour.

But both very useful without stopping you from using Result the way you want to...

One rather large problem I just ran into with Equatable conformance:

extension Result: Equatable where Value: Equatable, Error: Equatable { }

This unfortunately stops us from having it be equatable when Swift.Error is the Error type because you can't have multiple conformances.

If we had the Result<Value> style, then only Value has to be Equatable, because all Swift.Errors are convertible to NSError, and NSError is Equatable/Hashable...

I suppose we could make Swift.Error Equatable?

I would greatly prefer the pitched design but this approach would be much better than a Result type that does not allow custom error types at all.

I want to see this go through review but I honestly think that wanting Result is a symptom of the throwing and optional being suboptimal.

Could we have something like Result<Value, Optional<Error>> ?

Are you thinking of something like this?

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

If so, I would opposed this. There is absolutely no good reason to force the error type to be optional.

On the other hand, with the pitched design users would be free to use an optional error type if that is what they really need but the rest of us should not be forced to use optional for the error type.

5 Likes

It is not immediately clear to me that the current design has optional Error in mind.

/// The stored value of a failure `Result`. `nil` if the `Result` was a
  /// success.
  public var error: Error? { switch self { case let .failure(error): return error case .success: return nil
    } }

enum MyError: Error {

case er(String)

}

public enum Result<Value, Error> {

/// A success, storing a `Value`.

case success(Value)

/// A failure, storing an `Error`.

case failure(Error)

}

let myError: Error? = MyError.er("SomeError")

let someValueOptionalError = Result<String, Error?>.failure(myError) // This works

let someValue = Result<String, Error>.failure(myError) // error: value of optional type 'Error?' must be unwrapped to a value of type 'Error'