Extending the Result Type - More Expressive Error Handling

Result Type Proposal:

This is a proposal that I think could make error-handling easier and more expressive than the currently used Result API. The problem with the current Result API is that, although throwing an error can be really intuitive, error-handling is much less intuitive. What I propose is an extension to the current API that embraces a more modern swift syntax.

Proposed Syntax:

enum GetError: Error {
    case UnexpectedError
}

func getNumber() -> Result<Int, SendError> {
    //...
    guard let number = optionalNumber else {
         return .failure(.UnexpectedError)
    }
    return .success(number)
}

getNumber()
.success { value in
     print(value, "is the first number value.")
}
.failure { error  in
    print("An unexpected error occurred while trying to get the first number:", error)
}

Updated Functionality*:

getNumber()
.success { value in
     print(value, "is the first number value.")
}
.failure { error -> Result<Int, GetError> in
    print("An unexpected error occurred while trying to get the first number:", error)
    return getNumber()
}
.success { value in
    print(value, "is the second number value.")
}
.failure { error in
    print("An unexpected error occurred while trying to get the second number:", error)
}

Important Note:

*My implementation (you can read it at the "Updated Functionality Implementation") of the "Updated Functionality" uses the struct Data Type. So if you find a way to get the updated functionality working with the existing enum based Result Type implementation, please send it to me. If you've got any suggestions or feedback feel free to share it! Thank you!

Error Handling Improvements:

When throwing an error ( like in the "send" function), the new proposed API is identical to the current API. But what is different, is the way an error can be handled which is much more declarative and intuitive that the current API. That's because you don't need to use a switch or an if statement, as it is currently being done.

Implementation:

This new more declarative syntax could be achieved by extending the Result Type (implemented with the help of @sveinhal) as shown here:

extension Result {

    @discardableResult func success(_ handler: (Success) -> ()) -> Self {
        if case let .success(value) = self {
            handler(value)
        }
        return self
    }

    @discardableResult func failure(_ handler: (Failure) -> ()) -> Self {
        if case let .failure(error) = self {
            handler(error)
        }
        return self
    }
}

Updated Functionality Implementation:

  struct Result<Success, Failure> {
     private let value: Success?
     private let error: Failure?

     private init(value: Success? = nil, error: Failure? = nil) {
         self.value = value
         self.error = error
     }
  }

  extension Result {
      static func success(_ value: Success) -> Self { // Create a `Success` value to return as a `Result`
          Self(value: value)
      }
      static func failure(_ error: Failure) -> Self { // Create a `Failure` value to return as a `Result`
          Self(error: error)
      }

      private static func empty() -> Self { // Create an empty `Result` for when the `success` or `failure` method has executed to prevent the other method (if there is any) from running
          Self()
      }
  }

  extension Result {
      struct Handlers{
          typealias result<S, F> = Result<S, F>
    
          typealias success = (Success) -> ()// The simple `success` method:
                                                                 // getNumber().success { print($0) }
          typealias failure = (Failure) -> ()// The simple `failure` method:
                                                            // getNumber().failure { print($0) }
    
    
    
          typealias successResulting<S, F> = (Success) -> result<S, F>// The `success` method that
                                                                                                             // returns a `Result` on its own:
                                                                                                             // getNumber().success { _ -> Result<Int, GetError> in return getNumber() }
          typealias failureResulting<S, F> = (Failure) -> result<S, F>// The `failure` method that
                                                                                                       // returns a `Result` on its own:
                                                                                                       // getNumber().failure { _ -> Result<Int, GetError> in return getNumber() }
    
    
          typealias autoSuccess = () -> ()  // The `success` method that takes no input and returns `Void`
          typealias autoFailure = () -> ()  // The `failure` method that takes no input and returns `Void`
    
    
          typealias autoSuccessResulting<S, F> = () -> result<S, F>   // The `success` method that takes no input and returns a result
          typealias autoFailureResulting<S, F> = () -> result<S, F>   // The `failure` method that takes no input and returns a result
      }
  }

  extension Result {
      @discardableResult func success(_ handler: Handlers.success) -> Self {
          if let value = value {
              handler(value)  // If a value exists run the `success` handler with it
          }
          return self         // Return self to retain the chain
      }

      @discardableResult func failure(_ handler: Handlers.failure) -> Self {
          if let error = error {
              handler(error)  // If an error exists run the `failure` handler with it
          }
          return self         // Return self to retain the chain
      }
  }

  extension Result {
      func success<S, F>(_ handler: Handlers.successResulting<S, F>) -> Handlers.result<S, F> {
          if let value = value {
              return handler(value) // If a value exists run the `success` handler with it
          }
    
          return .empty()     // Return an empty `Result<S, F>` to retain the chain
      }

      func failure<S, F>(_ handler: Handlers.failureResulting<S, F>) -> Handlers.result<S, F> {
          if let error = error {
              return handler(error) // If an error exists run the `failure` handler with it
          }
    
          return .empty()     // Return an empty `Result<S, F>` to retain the chain
      }
  }

  extension Result {
      func success(_ handler: @autoclosure Handlers.autoSuccess) -> Self {
          return success { _ in // Use the existing `success` method and just ignore the value
              handler()
          }
      }

      func failure(_ handler: @autoclosure Handlers.autoFailure) -> Self {
          return failure { _ in // Use the existing `failure` method and just ignore the error
              return handler()
          }
      }
  }

  extension Result {
      func success<S, F>(_ handler: @autoclosure Handlers.autoSuccessResulting<S, F>) -> Handlers.result<S, F> {
          return success { _ in // Use the existing `success` method and just ignore the value
              return handler()
          }
      }

      func failure<S, F>(_ handler: @autoclosure Handlers.autoFailureResulting<S, F>) -> Handlers.result<S, F> {
          return failure { _ in // Use the existing `failure` method and just ignore the error
              return handler()
          }
      }
  }

Changes:

  1. The initial proposal was based on converting the Result Type from Enum to Struct and adding the methods that enable the more expressive API, but after some discussion with you, the community, the new API was implemented as part of an extension of the current type.
  2. I also changed the methods from onSuccess and onFailure to success and failure so as to make the API more cohesive and to convey to the developer the fact that the API is synchronous (something that the previous methods might not have done).
  3. I have updated the functionality (you can read it in the "Updated Functionality" Section) and therefore the implementation. The implementation has room for improvement, so please if you have any suggestions share them (for more go to the "Important Note" Section).
2 Likes

You don't have to make it a struct to have your onSuccess and onFailure functions. They can just as easily be implemented on the current enum:

extension Result {
    @discardableResult
    func onSuccess(_ handler: (Success) -> ()) -> Self {
        guard let case .success(value) = self else { return self }
        handler(value)
        return self
    }
    @discardableResult
    func onFailure(_ handler: (Failure) -> ()) -> Self {
        guard let case .failure(error) = self else { return self }
        handler(error)
        return self
    }
}
4 Likes

Or use if let case maybe instead. The point is, you can also do this for an enum.

I definitely agree! This was just an implementation that I thought could be more versatile than the current one. Your implementation is also able to achieve the same results. The point I want to get across is that I strongly recommend having the 'onSucess' and 'onFailure' methods on the Result Type without requiring a developer to extend Return by themselves, in order to make error-handling in swift easier.

In that case you should probably revise your pitch. There is virtually zero chance of replacing the current Result with a new type (which the title and original post suggest), but there might be a reasonable chance to extend the current type with these convenience methods that you suggest.

3 Likes

Thank you for the recommendations! I have updated the post to reflect a new API based on a new Result Extension, instead of a new Result Type.

Result has get() function.

do {
    print(try send().get())
} catch {
    print(error)
}

Thanks for the feedback! I am aware that there is a get() method in the Result Type. But what my proposal offers is a cleaner way to handler errors; what the Result API tried to achieve in the first place. This is the way it used to be done before Result was introduced:

func getNumber() throws -> Int {
    //...
    guard let number = optionalNumber else {
         throw GetError.UnexpectedError
    }
    return number
}

do {
    let number = try getNumber()
    print(number)
} catch {
    print(error)
}

I just want to improve on that and offer an even better more futuristic and more expressive way to do that:

func getNumber() -> Result<Int, GetError> {
    //...
    let optionalNumber = Optional(3)
    guard let number = optionalNumber else {
         return .failure(.UnexpectedError)
    }
    return .success(number)
}

getNumber()
.onSuccess { value in
    print(value)
}.onFailure { error in
    print(error)
}

I think it looks much cleaner than the old syntax.

I'm not convinced about naming. onSuccess, onError remind me of asynchronous operations, while here it's all happening synchronously.

I also don't see a real value to be honest. syntax of the current try way is not bad at all. This new methods are not more expressive than current syntax and swift devs are already familiar with the try catch pattern. I don't see why we should make an exception just for Result. It's a great point tough. working with enums leads to less expressive code by general. But I don't think this would be a solution to the problem. (in case of Result specifically I don't even see a problem to be honest).

6 Likes

The problem:

When chaining several calls in a long chain, it causes cognitive load and hurts readability when you have to combine left-to-right chaining and right-to-left chaining. You canā€™t simply scan the code in one direction and follow the flow. You need to mentally keep a stack in your head and push and pop as you read through the sequence of operations. Itā€™s not clear which call the try is applied to.

This solves the same problem apply solves, and operators such as |>, as well as forEach.

someCollectionReturningFunction()
    .map(transform)
    .filter(isValid)
    .first()
    .map(failableConversion)
    .flatMap(onlyApplicableIfSuccessfull)
    .onSuccess(handleSuccess)
    .onFailure { print($0) }
2 Likes

The problem I have with the suggested methods is that it might make it too easy to ignore error handling because I can just write onSuccess() and donā€˜t have to write the onFailure() part.

Using get() requires catching the error and using if let at least strongly suggests that an else clause might be a good thing.

6 Likes

I understand you concern, but in many cases there isn't a need to explicitly handle errors. And thereā€™s where the proposal of extending the current API comes in.

Just as one can write, _ = .map(...) or if case .success = result { ... } and ignore the failure part. This is often advisable.

The .get() method is great for converting the Result pattern back into a throws pattern, for compatibility when you need to pass a throwing closure to some function, or when overloading a function with a throwing declaration, but you still want to use the result pattern for error handling in the implementation. However, to use .get() in lieu of a switch, if case (or .onSuccess/.onFailure) seems very backwards to me. Why would you use a Result type if you're not going to use its convenience?

1 Like

I would normally use switch or chaining via map() or flatMap(). You are right that get() is intended to bridge
I listed get() more for completeness to show that the existing ways of handling a Result tend to encourage or enforce dealing with the error case.

1 Like

get() doesn't completely fill that gap though, as falling back to Swift error handling it loses type safety on the error.


To be fair this, taken a bit on an higher level, I think is a desire I've seen more than a few times: while I personally don't love fluent interfaces ā€” and I think Swift doesn't fully welcome them (see compilation speed) ā€” many people do.

The ideal would be to have the language be able to make things become fluent, and I think that with something as "simple" as being able to extend more types (e.g. extension Any or extension any T) then this

send()
    .onSuccess { print($0) }
    .onFailure { print($0) }

can become

extension Any {
    @discardableResult
    func fluent(_ operation: (Self) -> Void) -> Self {
        operation(self)
        return self
    }
}

send()
    .fluent { if case .success(let value) = $0 {
        print(value)
    }}
    .fluent { if case .failure(let error) = $0 {
        print(error)
    }}

which of course isn't as lean as the specific utilities, but at least it enables this kind of writing style.

1 Like

I think this should be possible for any enum rather than create a special case for Result.

result.do(onCase: .success) { value in ... }
    .do(onCase: .failure) { error in ... }
1 Like

I donā€™t think that is going to happen any time soon, because what you are proposing is a change in a fundamental data type of swift; the enum. Your proposal seems interesting, though, so if thereā€™s a more descriptive proposal Iā€™d appreciate if you could tell me. But even if there was such a method (ā€˜doā€™), I think that Result is a commonly used type and therefore a more descriptive API would be really useful to developers.

Today this is already possible and I've actually built a library about it (there is also the do(onCase: ) function), after pitching the idea.

2 Likes

I made some changes to the Proposal that can be seen under the Changes section of the Proposal at number 2. The changes can be summarized into changing the methodsā€™ names to success and failure.

Note that what you propose has been considered already as part of the initial proposal to add Result. It was rejected with this rationale:

  • The value() , error() , and isSuccess() methods should be removed. They are useful sugar, but can and probably should be handled by some more general language feature that applies to all enums in Swift automatically.

(Note that at the time of that review, the cases were named ā€œvalueā€ and ā€œerror.ā€)

What the core team is asking for, therefore, is exactly what you describe below as unlikely:

5 Likes