[Pitch] Result: Codable conformance and async "catching" init

Hi everyone,
Here's a small pitch that I've drafted because of an usability issue with Result while working with some adopters.

The short version is, I'd like to propose we:

  • add Codable conformance to Result when its Success and Failure are both Codable
  • add an async overload for init(catching:) using typed throws, in order to be able to convert an async operation into a well typed result

The full proposal is here in a PR, please suggest any editorial changes on the PR and discuss the general idea in this pitch thread.

edit: since I did some edits, removed the inline version -- please review the linked PR :slight_smile:

12 Likes

Async initializer: big yes, need this one all the time.

Codable conformance: eh? I generally don't trust auto-synthesized Codable with non-RawRepresentable enums anyway; it's very weird and never matches any other convention for storing enums in JSON. I can't imagine a situation where I'd want or use it; I'd always end up writing a property wrapper or similar to encode in another way.

For those too lazy to read the enum coding spec, the encoding in JSON would be:

{
    "success": {
        "_0": <encoded success>
    }
}

or

{
    "failure": {
        "_0": <encoded failure>
    }
}
10 Likes

I'm generally a fan of more Result API, and personally use a set of async functions that turn Result into a type of Promise, allowing me to chain sync and async work while preserving error information along the way.

However, I think it's about six years too late to add a Codable conformance. Unlike the new functions, where collisions can be mitigated, there's no way to make this change compatible with existing code. Additionally, the default Codable synthesis for enums is never what anyone would write, so it's only useful in the narrow case where you're persisting a value and don't care about the schema. I'd suggest we don't do anything here.

I also don't understand this point:

None of these additions require writing your own Result type, you can implement the functions and Codable yourself just fine. The community has been doing that this whole time. In fact, I'd say that adding a Codable conformance would actually create more work than not, as users would have to move their conformance to a custom type, as the synthesized conformance wouldn't be compatible with what they had.

8 Likes

I maintain packages with what you're talking about, and would love not to. Thank you in advance.

Don't oversell this, though. That closure needs to be annotated. :pensive_face:

let result: Result<_, SomeCodableError> = await .init { () async throws(_) in
The whole compiling example
func accept(_: some Codable) {  }
struct SomeCodableError: Error & Codable { }
func compute() async throws(SomeCodableError) -> Int { .init() }

let result: Result<_, SomeCodableError> = await .init { () async throws(_) in
  try await compute()
}

accept(result)

I'm in agreement that Codable synthesis for enums with associated values is not good. Can't you just do better, though?

struct Failure: Error & Equatable & Codable { }
typealias Result = Swift.Result<String, Failure>
func test(_ result: Result, _ json: String) throws {
  let data = try JSONEncoder().encode(result)
  #expect(String(data: data, encoding: .utf8) == json)
  #expect(try JSONDecoder().decode(Result.self, from: data) == result)
}
try test(.success("πŸ€"), #"{"success":"πŸ€"}"#)
try test(.failure(.init()), #"{"failure":{}}"#)
extension Result: Codable
extension Result {
  private enum CodingKey: Swift.CodingKey {
    case success, failure
  }
}

extension Result: @retroactive Encodable where Success: Encodable, Failure: Encodable {
  public func encode(to encoder: any Encoder) throws {
    var container = encoder.container(keyedBy: CodingKey.self)
    switch self {
    case .success(let success): try container.encode(success, forKey: .success)
    case .failure(let failure): try container.encode(failure, forKey: .failure)
    }
  }
}

extension Result: @retroactive Decodable where Success: Decodable, Failure: Decodable {
  public init(from decoder: any Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKey.self)
    do { self = .success(try container[.success]) }
    catch { self = .failure(try container[.failure]) }
  }
}
public extension KeyedDecodingContainerProtocol {
  subscript<Decodable: Swift.Decodable>(key: Key) -> Decodable {
    get throws { try decode(Decodable.self, forKey: key) }
  }
}
1 Like

Rather than conditionally conforming to Codable, why not conditionally conform to Decodable and Encodable in separate extensions?

extension Result: Decodable where Success: Decodable, Failure: Decodable {}
extension Result: Encodable where Success: Encodable, Failure: Encodable {}
9 Likes

Yeah, that’s a good point, thanks.

That would make it more acceptable, as it's most likely to match what people have already written, but there will still be those cases where it doesn't. I learned this recently when Xcode 16.3b1 added a Codable conformance to CLLocationCoordinate2D (still in beta 2) which broke our decoding, as we weren't using the obvious encoding. Which reminds me...

@ktoso I've been assuming that deploying new protocol conformances was still an issue with the language (and the CLLocationCoordinate2D conformance was causing a linker crash on older OSes last I checked). Has that been solved or am I confusing it with something else? (I seem to recall some type recently had an Equatable conformance added but were unable to back deploy the conformance, leaving only the == implementation available.) So perhaps it's just that the conformance can't be back deployed, even if the requirements can be?

1 Like

+1 on the async init. I have this method defined in at least three of my projects. It needs the usual isolation parameter as long as SE-0461 is not accepted.

public init(
  isolation: isolated (any Actor)? = #isolation,
  catching body: () async throws(Failure) -> Success
) async {
9 Likes

definitely a +1 on the async init part! (I just ran into the issue :>)

for the Codable conformance, generally +1, though others mentioned the default synthesized implementation isn't ideal. I personally am not a Codable expert (I don't have enough knowledge to do a simple implementation), so I think I can't give an opinion on that.