[Pitch] Async Result Support

Hello All!

I'm trying to pick back up some [work]( [Pitch] Result: Codable conformance and async "catching" init ) originally started by @ktoso : better async support for Result. Overall these two proposed changed are quite small. Originally, he had suggested added the async catching init. And this is something I think will be very appreciated.

The second addition, of a result-based Task accessor, was a little more of a whim. I think it's a nice little thing, but I also haven't found myself needing it very often. I took the time to write it up and make the case for it. However, I'm pretty open to push back on this one. So, if you think it isn't worth it and/or a bad idea, please let me know.

It's short, so I'm just including most of the proposal text here. But I've also opened a PR, and that's a great place for editorial feedback.

Motivation

The existing Result.init(catching:) initializer is a useful tool for transforming throwing code into a Result instance. However, there's no equivalent overload that can do this for asynchronous code. While this isn't particularly difficult to write, because of the utility, it ends up being manually duplicated in many code bases.

Such an initializer would make the following possible:

let result = await Result {
  try await asyncWork()
}

It could be useful to take this even further. A Task wraps up possibly-throwing asynchronous work, the output of which is exposed with an accessor. A programmer might want to transform this output, a thrown error, or possibly both. These are exactly the kinds of operations that the Result API is intended to express.

Having an asynchronous initializer helps make these two types more compatible. But, a Result-based accessor provides a more streamlined interface. It also matches the continuation overloads that accept Result types nicely.

Here's how that might look in practice:

let result = await Task {
    try await asyncWork()
  }
  .result
  .flatMap { transformValue($0) }

Proposed solution

Both problems can be solved with additions to the standard library. First, by adding an async overload of the catching initializer. And second, a convenience property on Task that provides access to a Result-based output.

Detailed design

Here are the two proposed API changes.

Result initializer

extension Result where Success: ~Copyable {
  /// Creates a new result by evaluating a throwing closure, capturing the
  /// returned value as a success, or any thrown error as a failure.
  ///
  /// - Parameter body: A potentially throwing asynchronous closure to evaluate.
  @_alwaysEmitIntoClient
  public nonisolated(nonsending) init(catching body: () async throws(Failure) -> Success) async {
    do {
      self = .success(try await body())
    } catch {
      self = .failure(error)
    }
  }
}

Task accessor

extension Task {
  @_alwaysEmitIntoClient
  public var result: Result<Success, Failure> {
    async get {
      Result { try await self.value }
    }
  }
}

What do you think?

21 Likes

Everybody likes the first change, but the second one is confusing to me—is it a change?

1 Like

Ha well look at this! Not the first time I have proposed something that already exists and will not be the last. Will update.

6 Likes

I think the broader issue is that there is no reasync keyword similar to rethrows, and any API that is introduced to implement an async version of something that could be reasync will just mean that much more stuff to clean up (or in worse case keep) if/when reasync is added.

We've needed reasync for five years. It's untenable to allow it to block improvements like this when there's no timeline for its addition, or even agreement that it will ever be added in the first place.

8 Likes

Async support in Result can make it a pseudo-promise type, so I'm all for it. I end up adding some variant of all these APIs to most of my projects, so adding them to the type would be great. However, I would suggest examining how well they can back deploy to minimize the need for the community to continue building their own versions on older OSes. Since most of these APIs are just functions they should be fine, aside from where the latest concurrency features are limited by runtime support.

4 Likes

I’m glad you brought up reasync. I tend to forget about it, but I agree that it’s something I’d like to see discussed again.

In the meantime though, I cannot imagine this particular change would be difficult to clean up if reasync came into being. But I guess we cannot know what the actual implications could look like. So I concede that it is possible any changes could be a problem. I’ll have to defer to others on this.

I think if it's @_alwaysEmitIntoClient then it can be safely deleted, otherwise need to keep it in the source code, like this (unless reasync will compile down to 2 separate functions with the async's mangled name being the same as the old one): swift/stdlib/public/core/Result.swift at 0a82ff34460a09f2ad9d0ed1b9d2f3c8cce1a041 · swiftlang/swift · GitHub

For what its worth: I think there will be a few places in async-algorithms we can delete if this is something that ends up being a thing.

3 Likes

I can't count how often I copied this extension around. So yes please let's add it.

2 Likes

If this is about aligning Task and Result APIs, could it be a good opportunity to add a throwing value property to Result?

1 Like

Task.value and a hypothetical Result.value are not as similar as they might at first seem.

  1. Task.value cannot use typed throws, because it throws multiple error types. (This would cause problems related to this pitch if it ever became possible to create Tasks with anything other than any Error as Failure, because result could not be available for those tasks.)

  2. Task.value being settable is not sensical, but Result.value should be settable. The time will not be right for Result.value until set accessors can also throw. Until that point, we actually need a method to match get, rather than a conversion from get to value.

public extension Result {
  @inlinable mutating func set(_ newValue: @autoclosure () throws(Failure) -> Success) {
    self = .init(catching: newValue)
  }
}
enum Failure: Error { case failure }
var result = Result<_, Failure> { 0 }

var failure: Int { get throws(Failure) { throw .failure } }
result.set(try failure)
#expect(throws: Failure.failure, performing: result.get)

var success = 0
result.set(1)
try? success = result.get()
#expect(success == 1)

I don’t think I understand how this follows from the linked documentation. It does mention CancellationError, but only as something that the task could choose to throw. As far as I understand, Task will never create a CancellationError on its own.

I don’t see how a throwing setter would be applicable here. I’d imagine a throwing setter as something that performs some sort of validation on the new value and throws if it is not allowed to be set, not something that can catch an error thrown by the right hand side of the =.

We want Task.value to be typed throws however we have faced source compatibility issues introducing typed throws to Task.init.

Jed is correct that the task is never throwing "out of nowhere" therefore typed throws adoption is safe and correct, just that we need to stage in this change carefully.

2 Likes

@mattie thanks for picking this up. I remain supportive of the change, let's just do the Result initializer without adding anything else -- should be a quick and easy addition and review.

The signature is slightly wrong here:

should be

public 
nonisolated(nonsending) 
init(
  catching body: nonisolated(nonsending) () async throws(Failure) -> Success
) async {

the inner nonsending is important here as well.

5 Likes

Misunderstood, learnt, thanks.

As shown above,Result.value.set specifically should not throw. But it does not make sense to add throwability to properties' newValue without being able to set the error for the whole operation. The other* three combinations are all useful for other cases (and while setting Task.value isn't sensical, setting other properties asynchronously can be).

Result.value
public extension Result {
  var value: Success {
    get throws(Failure) { try get() }
    set(throws(Failure)) throws(Never) {
      self = .init { try newValue }
    }
  }
}

* Never/Never is the only allowable combination right now; other combinations require dropping settability:

public extension Result {
  var value: Success {
    get throws(Failure) { try get() }
  }
}

public extension Result where Failure == Never {
  var value: Success {
    get { get() }
    set { self = .init { newValue } }
  }
}

It will be!

What was really going for here was a targeted addition to address this very commonly-added extension. I haven’t thought much about adding Result.value as alternate to Result.get(). But now that I’m looking at it, it makes sense to me.

If others feel strongly that this is something to consider, I’m open to it.

I’ve been annoyed by this not being available for too long! I’m very happy to do it, but really you did most of it.

As for the missing inner nonsending, yes @FranzBusch caught that as well. I have updated the proposal text for this, as well as removed the… ahem… unnecessary addition of Task.result.

4 Likes