SE-0235 - Add Result to the Standard Library

I was the one to originally suggest this function:

I mentioned it specifically because some people were having trouble understanding how you can perform certain operations that are not expressible by maps and flatMaps alone. It's basically a function version of a switch statement over the result.

And yeah you can implement map, mapError, flatMap and flatMapError in terms of fold, but many times it's more concise to use the simpler, less general function.

The same is true of reduce on arrays. You can reimplement all of array's API in terms of reduce (including map, suffix, prefix, count, filter, etc.) in terms of reduce, but of course you want probably the more specific functions most of the time. However that doesn't mean we shouldn't also have the more powerful reduce.

3 Likes
  • What is your evaluation of the proposal?

+1!

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes, there are many times I want to treat errors as data that can be transformed just like I would do with the data. There is very common when dealing with errors that don't travel very far and I want to work within a fully exhaustive set of errors.

  • Does this proposal fit well with the feel and direction of Swift?

Yeah

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I use Result all the time for server side Swift when dealing with domain specific errors, like form validation or API errors with 3rd parties (e.g. Stripe, GitHub).

The fold operation is also indispensable when you start to use Result enough and realize there are many transformations you want to do that are not expressible by maps and flatMaps alone. It's definitely an advanced feature of the type, but then again so is reduce on arrays.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

In-depth.

2 Likes

It’s not exactly the same because it doesn’t trigger a Swift error throw breakpoint, which try? would do.

That can be a big deal when designing an API because (say) a Networking layer that uses Result.failure for not-really-error conditions (such as a 302 response or value-not-in-cache) can end up being awkward to debug if it triggers that breakpoint under normal usage (this is another use case for using Result, in fact).

That said, I think it’s generally bad practice to unwrap success without handling failure, so I’m cool with removing the value/error/isSuccess/etc getters and expecting that people will use switch, if case or map() instead as appropriate.

2 Likes

If the idea is to be able to map over both values with a single action, aren't we talking about bimap?

Although, where map is right-leaning, and allows you to operate on Result as if it were an Optional, bimap is left-leaning, potentially allowing you to shortcut in case of Error.

The difference from bimap is that both transforms provided to fold must return a value of the same type.

Is this still the case after SE-230 was accepted?

1 Like

fold is still different than that. Note that the signature is

fold: ((Value) -> C, (Error) -> C) -> (Result<Value, Error>) -> C

as opposed to

bimap: ((Value) -> NewValue, (Error) -> NewError) -> (Result<Value, Error>) -> Result<NewValue, NewError>

fold is more general than map, flatMap, mapError, flatMapError and even bimap. You can implement them all with just fold, but you cannot implement fold with just those functions.

1 Like

Not if the success type is optional, no.

1 Like
  • bimap would be a function that combines map and mapError:
    func bimap(
      transformValue: (Value) -> NewValue,
      transformError: (Error) -> NewError)
      -> Result<NewValue, NewError>
    
  • fold allows you to break out of the Result:
    func fold(
      ifValue: (Value) -> A,
      ifError: (Error) -> A)
      -> A
    

Edit: whoops didn't see @mbrandonw beat me to the punch.

1 Like

Yeah, that’s what I meant. You guys had the more complete answer. :slight_smile:

fold is still different than that.

That is very true. It's just that the signature feels a bit ambiguous. fold should iterate over the enumeration, right? Shouldn't it behave and sport a signature similar to a Sequence.reduce, then? (and, also, be called reduce), basically generalizing fold for all CaseIterables.

I might be wrong, but I think bimap would fulfill more cases, most people know what "map" is by now, it's used more often than reduce, and if we were to keep "fold", it's yet another <ambiguous-FPy-word-I-dont-get-so-I'll-ignore-it> type of term that a lot of people will have to learn on top of a whole new type.

I think it'd be better to handle situations like this by mapping the failures to successes. For example (adopting some of my suggestions):

func handleResponse(_ origResult: Result<(URLResponse, Data), HTTPError>) {
  let result = origResult.correcting { error in
    switch error {
    case HTTPError.movedPermanently, HTTPError.seeOther,
         HTTPError.temporaryRedirect:
      return (error.urlResponse, Data())
    default:
      throw error
    }
  }
  // Now write the rest of the function using `result`.
}

I generally agree, of course the devil is in the details, isn't it! :slight_smile:

I thought we were going to need to decide this before a Result proposal would make it to review, but here we are. I actually think that's ok. I am also fine with making the decision now as long as it is decided in favor of #2. :slight_smile:

I would strongly oppose a Result that did not allow for typed errors. That removes flexibility that is occasionally extremely useful. I would prefer to not have Result in the standard library if it were limited to the untyped variant. The typed design allows people to simply use Result<T, Swift.Error> to recover the behavior of an untyped result (and even introduce a typealias if desired). The untyped design forces those of us who find typed errors useful to fight the language one way or another. The current state of choosing third party libraries or rolling our own is much better than having to fight the language.

This was discussed extensively in the pitch thread. I wish you would have participated there!

Adding throws to transforms on Result is categorically different than it is on other types, such as collections and optional. The version of map you advocate for is a syntactic transformation of flatMap semantics. I think it's important that the name reflect the semantics of the operation. Using map here does not feel right to me.

Further, in Swift 5 (where this would be accepted) your map only works when Error == Swift.Error but surely we want map to be included without constraints. Your version of map could be included in a constrained extension along side an unconstrained non-throwing map. That would be a simple renaming of the throwing flatMap in the proposal.

Of course this would be subject to the concern @John_McCall brought up a concern regarding overloading only on whether the transform throws or not. I am unsure how often problems would arise in practice - people are unlikely to want to return Result from the throwing overload and must return Result from the non-throwing overload. However, the bar for acceptance in the standard library should be very high and the potential for problems with this overload is certainly present. I now believe throwing transforms should not be included if the proposal is accepted.

I don't believe there are good alternative names for the methods with throwing transforms. People could bikeshed if desired, but very much doubt anything remotely resembling consensus would emerge. It is trivial for those who want them to add them in extensions. If substantial experience proves that the overload is not a problem perhaps they could be proposed down the road.

Assuming we did have typed throws, the type signatures you proposed would perhaps make good replacements for Result-returning flatMap and flatMapError methods in the proposal. However, the names should remain the same. The semantics of flattening remain and are distinct from a simple map.

This issue is significant in the case of your mapError which returns Value rather than a new error type. When users actually just want to map the error they would be forced to provide a function which always throws. That is rather unusual and certainly unprecedented in the standard library.

I see no good argument for this whatsoever. This introduces jargon for jargon's sake. Swift programmers are becoming quite familiar with map and it's variants - flatMap, compactMap and mapFoo (where Foo selects part(s) of an aggregate).

This change happened for good reason - the method now named compactMap was never semantically a flatMap and that caused plenty of confusion. Another name that was considered for this operation is filterMap which expresses the semantics of the operation rather clearly - it is single operation that both maps and filters, just as flat map is an operation that both maps and flattens. We definitely should not introduce new names for an operation that is semantically equivalent to a flatMap.

I strongly oppose including an initializer like this. The use case is common enough but there simply is not a design that is obvious enough to warrant inclusion in the standard library. It is impossible to know a-priori how to handle the two nil case, and especially the value and error case. If this initializer is included in the standard library people will use it whether or not it is appropriate for a specific use case.

4 Likes

Without the need to sacrifice any flexibility here, I think it‘s fair to say that #2 is just the right way to go regardless wether we will support typed throws or not. #2 is also just perfect for all people that do support only #1. The trick is that we want to support default types for generic type parameters in the future, we just don‘t have them right now!

So the proposed result type will eventually become both #1 and #2:

public enum Result<Value, Error = Swift.Error> { 
 }

Edit:

I would like to extend the idea even further. @Slava_Pestov what if instead of letting the Error protocol conforming to itself, we consider a slightly different approach by allowing a new type of constraint for where clauses?! Furthermore I think making Error conforming to itself might shoot us in the foot in the future where we won‘t be able to implement a feature because of that exclusive exception on how protocols behave.

Instead we may allow a new constraint which describes that a generic type parameter either conforms to a specific protocol or is that protocol existential. Let‘s make it :== for now.

With this constraint we don‘t have to add hacks like self conforming protocols, but we potentially lose covariance part of your pitch.

// Error defaults to `Swift.Error` if it‘s not specified explicitly and `Error` must
// conform to `Swift.Error` or be `Swift.Error`. 
public enum Result<Value, Error = Swift.Error> where Error :== Swift.Error { ... }
3 Likes

What is your evaluation of the proposal?

Big +1.

Is the problem being addressed significant enough to warrant a change to Swift?

Yes, at the moment this is a big problem that's finally being addressed. I think most people here in this thread are looking too much into the future. Yes, we will have maybe have a better solution a couple of years down the road but does that mean that we have to write mediocre code and end up with a bunch of different implementations in every project? This is the main reason why I think this proposal should be accepted with the current state of Swift.

Does this proposal fit well with the feel and direction of Swift?

Yes, as it can be seen from the tens of thousands of people that implemented this solution in their own projects.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Haskell uses a very similar way of handling two possible exclusive values with Either. Writing code in Haskell for a short amount time during my academic time made me fall in love with the way that a language can enforce writing correct and simpe to understand code. I think Swift would only benefit by adding accepting this proposal.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Read the proposal, read the first 50 replies to this thread and skipped the remaining since there are too many. :+1:

I think anything added to the Swift standard library right now should only be provided as a way to propagate errors across different execution contexts / asynchronous callbacks. i.e. something more analogous to C++'s std::exception_ptr rather than a full-blown Try type. And even for this use case I would prefer to wait for development on the async/await front.

Sure, other languages have a mixture of Try/Result types as well other error handling mechanisms, but the proposal's examples aren't very compelling (except for the asynchronous case) and don't do a great job of explaining how Result and do/catch/try will co-exist. Will people use Result to entirely co-opt the native do/catch/try error handling mechanism? Is this going to become the next "exceptions vs. return codes" discussion (or rather, "Result vs. do/catch")?

The lack of language integration, or discussion of this in the proposal is also a concern: if language affordances are to be added later, shouldn't some initial examples/design be described in the proposal to ensure the type is designed with them in mind?

Also, is there a plan for any automatic bridging of ObjC APIs? Or annotations to map completion callback arguments to Result arguments? The URLSession example certainly highlights real problems, but will the addition of Result actually lead to improvement in any of these existing APIs? And what happens when async/await comes long?

3 Likes

Ouch, Soroush, tell us what you really think :wink:

My experience is basically the opposite: I use Result all the time and have never used Promise.

However, I don't really care if this proposal makes it into the standard library or not. As I mentioned in my article Values and errors, part 1: 'Result' in Swift, Result is a trivially simple type and translating between implementations is easy and rare so putting it in the standard library isn't really necessary.

I'm far more worried about negative impacts this proposal might have on runtime performance if 2 generic parameters must be resolved during Swift type metadata lookup. All I can request is that Swift devs perform tests and offer assurances that Result<Value, Error> will be equivalent performance to Result<Value> if the Error type is hard-coded to be Swift.Error – including in cases where the Value is unspecialized and not known at compile time.

11 Likes
  • What is your evaluation of the proposal?

+1

  • Is the problem being addressed significant enough to warrant a change to Swift?

Very much! I'm using it in all my projects and very often I'm dealing with multiples implementations at the same time which is unfortunate. I would love to see universal solution provided by the standard library.

Does this proposal fit well with the feel and direction of Swift?

Generally very well. I'm very much in line with @beccadax thoughts here, but in a strong favour of typed errors.

Although in most cases they are not need, there are occasions when typed errors are very useful. I've stumbled upon such cases many times and I would rather not have Result in the standard library than have one with untyped errors. That being said, I'd also love to have Result that is simple to use with untyped errors, like Slava suggested by making Swift.Error conforming to itself. I think this would be a big win, not just for Result, but also for other types like Futures or Signals.

In addition to that, I'd also love to see Error generic parameter defaulted to Swift.Error. In other words, my ideal Result type would be defined like: Result<Value, Error: Swift.Error = Swift.Error>. I know this is not possible now because default generic parameters are not supported, but they are in the manifesto and have their usefulness so I hope to see them supported eventually and then the whole discussion about typed vs. non-typed error becomes pretty much pointless. Therefore, let's us not constrain ourselves to untyped errors.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

In-depth study.

Hello,

Does the proposal stand the most basic of the test, which is, at the bare minimum, to deal with our CURRENT error system, which is untyped?

I've built a playground in order to assert that the untyped flavor of the proposed Result can be used in basic situations, and see how it behaves in some corners: GitHub - groue/SE-0235: A playground which explores SE-0235: https://forums.swift.org/t/se-0235-add-result-to-the-standard-library/17752. This playground uses the Result type as shipped with the latest version of https://github.com/apple/swift/pull/19982

  1. Generally speaking, it is quite OK. I could assert that a fairly large deal of normal usage can be done.

  2. There is a cost, which is the definition of a typealias. I've used AlamofireResult, because Result<T, Swift.Error> is close to the current Alamofire.Result, which is probably the most used untyped result type out there. If anybody has a better idea, please speak up :slight_smile:

    The topic of this typealias is important: I would not be happy having to type the verbose Result<T, Error> everywhere, especially considering that I'm not supposed to fight the language when I use untyped errors, which is, I repeat, our CURRENT error system. I don't understand why the proposal wants to punish me, here.

  3. There are two misuses that the proposed Result does not see: SE-0235/Contents.swift at 2af6102b77caa8d16587bbfa8d8c0a2e55592e7b · groue/SE-0235 · GitHub and SE-0235/Contents.swift at 2af6102b77caa8d16587bbfa8d8c0a2e55592e7b · groue/SE-0235 · GitHub

    _ = AlamofireResult.success(1).flatMap { "\($0)" }
    _ = AlamofireResult.success(1).flatMap { _ in throw SomeError() }
    

    Those are interesting, because we recently introduced compactMap in order to fight the same misuse, in the domain of optionals.

  4. Finally, there is one "ambiguous use of 'flatMap' " compiler error SE-0235/Contents.swift at 2af6102b77caa8d16587bbfa8d8c0a2e55592e7b · groue/SE-0235 · GitHub :

    func incrementResult(_ int: Int) -> AlamofireResult<Int> { return Result.success(int + 1) }
    // compiler error: ambiguous use of 'flatMap'
    let result = AlamofireResult.success(1).flatMap(incrementResult)
    

    I can't tell if this is a big problem or not. I, personally, don't write much functions which return Result, and prefer throwing functions. But I wonder what other Swift users think.

Conclusion: when one focuses on the integration of the proposed Result with the current error system of Swift, which is untyped, we see that the proposal generally fits the bill. I could find three issues, though.

6 Likes

That is very interesting actually. The last flatMap that throws is actually troublesome for a few reasons. As implemented where the concrete Error type is Swift.Error it will propagate any possible error that the closure might throw to the new result.

Hypothetical implementation of the counterpart with typed throws:

extension Result where Error : Swift.Error {
  // The transformation would require to throw the same type, because
  // there is no way to transform the current `Error` to something different
  // if the closure would throw a different error type.
  public func flatMap<NewValue>(
    _ transform: (Value) throws<Error> -> NewValue
  ) -> Result<NewValue, Error> {
    switch self {
    case let .success(value):
      do {
        return .success(try transform(value))
      } catch {
        return .failure(error)
      }
    case let .failure(error):
       return .failure(error)
    }
  }
}

After that little mind experiment I don't think this methods can be called flatMap because they're not flattening the result of the closure. The behavior feels like a compactMap indeed.

Interestingly this brought me to an idea of a general map and flatMap methods, which are currently not expressible but make quite a lot of sense. To solve the above limitation where the throwing transformation might return a different type we actually need to add typed rethrows (or just throws for flatMap).

extension Result {
  public func map<NewValue, NewError>(
    _ transform: (Value) throws<NewError> -> NewValue
  ) rethrows<NewError> -> Result<NewValue, Error> {
    switch self {
    case let .success(value):
      return .success(try transform(value))
    case let .failure(error):
       return .failure(error)
    }
  }

  public func flatMap<NewValue, NewError>(
    _ transform: (Value) -> Result<NewValue, NewError>
  ) throws<NewError> -> Result<NewValue, Error> {
    switch self {
    case let .success(value):
      switch transform(value) {
      case let .success(newValue):
        return .success(newValue)
      case let .failure(error):
        throw error
      }
    case let .failure(error):
       return .failure(error)
    }
  }
}

These methods can be very useful and they show that the currently proposed map and flatMap methods are in the way. Therefore I'd like to point out again that we should rename the proposed methods to mapValue and flatMapValue.