Treat Results like Optionals?

Optionals, while not unique to Swift, are of course one of its most core features. But since Swift 5, it's also had a Result<T,E> type that in many ways behaves similarly. Both are enums, often indicating a potentially failed/aborted result, but Result gives you a bit more information about why something failed, whereas with Optional you just have to guess.

One problem with Result, in my opinion, is ergonomics. I often want to use result types in similar ways as optionals, and Swift has really nice expressive support for use cases around Optional, but for the analagous Result usages, it suddenly becomes a nightmare.

Let's say I want to get a value from a dictionary or default to 0 if the key doesn't exist:

let value = dict[key] ?? 0

Easy Peasy! But if I want to do something similar with a Result? Here are my options:

let value: Int = (try? returns_result_type().get()) ?? 0 // Yucky; I will be dead in the grave before I use error throwing for control flow

// Or maybe...

var value: Int
switch(returns_result_type()) {
case .success(let x):
    value = x
case .failure(let err):
    value = 0
}

// Way too long considering the simple semantics, too hard to read, just terrible.

// Or maybeeee...

extension Result {
    func getOr(_ fallback: @autoclosure () -> Success) -> Success {
        switch self {
        case .success(let value): return value
        case .failure: return fallback()
        }
    }
}

let value = returns_result_type().getOr(0)

// ... Hooray???


This is a big problem in my opinion for two reasons:

  1. Using Result when it does appear is gunky
  2. Developers are encouraged to use Optional over Result even when Result would make more sense, because Optional is so much more ergonomic.

About that second problem: Consider something like Int.init(_ text: String), which could fail if the text is non-numeric, or if it is numeric but would overflow (Granted, this is a constructor, but you see what I mean). This seems like the perfect use-case for result types, but it gives an Optional instead. Without that extra information, if the function returns nil then I have to give my user a message like "The number you inputted was invalid" instead of a more specific "Please enter a shorter number." Or I implement the validation myself, but then what's the point anymore. If Result was easier to use at the call site, I think developers would be less afraid to put it in libraries where functions have multiple distinct failure modes.

So what can we do about it?

I suggest extending the already-in-place tools surrounding optionals in Swift, like ??, to work with Result as well. This could be done through a few avenues. The simplest would be to just add a method like Result.toOptional() which could be called whenever you don't care about distinct failure cases. Another option would be implementing some kind of shared interface across Optional and Result which operators like ?? could use. This interface could even be extended DIY, like if someone wanted to implement something like Haskell's Validation to Swift. Lastly, Swift could just add more useful methods to Result, but I think that lacks imagination.

What do you think? Am I the only one in Swift who still cares about Result after async/await?

The feature that makes Result generally obsolete is typed throws, not async/await.

The missing feature that would help with what you're talking about, using those, is do/catch expressions.

let value = do { try returns_int() } catch { 0 }

Result is too antiquated to warrant more official support, but you can add it yourself.

let value = returns_result_type() ?? 0
public func ?? <Value>(
  _ result: Result<Value, some Error>,
  _ default: @autoclosure () -> Value
) -> Value {
  do { return try result.get() }
  catch { return `default`() }
}
3 Likes

Really it's both, because prior to async/await it was a lot more common to need to pass errors into callbacks.

3 Likes

It never made sense to me that people used Result for those callbacks, while using throws for synchronous code instead of returning Results. You could always define your promises as the equivalent of today's get throws. The translation to modern syntax is more direct, if you did.

enum Failure: Error { case failure }

import typealias Combine.Future
func attemptToFulfill(completion: Future<Int, Failure>.Promise) {
  completion(.random() ? .success(1) : .failure(.failure))
}

typealias Promise<Success> = (() throws -> Success) -> Void
func attemptToFulfill(completion: Promise<Int>) {
  completion(.random() ? { 1 } : { throw Failure.failure })
}

typealias TypedPromise<Success, Failure: Error> = (() throws(Failure) -> Success) -> Void
func attemptToFulfill_typed(completion: TypedPromise<Int, Failure>) {
  completion(.random() ? { 1 } : { () throws(Failure) in throw .failure })
}

func attemptToFulfill() async throws(Failure) -> Int {
  if .random() { 1 } else { throw .failure }
}
attemptToFulfill { result in
  switch result {
  case .success: break
  case .failure(let failure): _ = failure as Failure
  }
}

attemptToFulfill { getValue in
  do { _ = try getValue() }
  catch let failure as Failure { _ = failure as Failure }
  catch { fatalError() }
}

attemptToFulfill_typed { getValue in
  do throws(Failure) { _ = try getValue() }
  catch { _ = error as Failure }
}

do { _ = try await attemptToFulfill() }
catch { _ = error as Failure }

Nice.

You could... Nevertheless > 99.9% of code was not doing that but something as simple as execute: (Data?, Error?) -> Void for callbacks or its execute: (Result<Data, Error>) -> Void version.

1 Like

Slightly tangential, but you could represent this more succinctly as:

let value = dict[key, default: 0]
1 Like