Using Result type with async/await

We have an async api that uses the Result type. Like this:

func fetchProducts() async -> Result<[Product], Error> {...}

We call it like this:

let result = await fetchProducts()

The primary reasons for us to use async would be to avoid a lot of nesting. The obvious, type-safe, way to handle the bove result would be a simple switch, like this:

switch result {
    case .success(let products):
        // Handle success
    case .failure(let error):
        // Handle error
}

but now we are back at nesting. If we want to call another api inside the success we are nested one level more. For each call, the nesting increases.

The only solution we have been able to come up with is

guard case .success(let products) = result else {
    handleFailure(result)
    return
}

but then the handleFailure() func needs to do pattern-matching again to unwrap the error, even though we already know that it must be the .failure case.

Is there a more elegant and ergonomic solution?

For a various of reasons we do not want to have the api throwing so please do not suggest that.

3 Likes

One solution is

let products: [Products]
let result = await fetchProducts()
switch result {
    case .success(let p):
        products = p
    case .failure(let error):
        handleError(error)
        return
}
// use products

but it seems quite verbose to me.

If you're using async/await, you can make the function throwing.

func fetchProducts() throws async -> [Product] {...}

The only thing you lose is typed errors.

1 Like

For a various of reasons we do not want to have the api throwing so please do not suggest that.

1 Like

I don't think it's fair to needlessly wrap the result and then expect some magic way to unwrap it without ceremony. By refusing to consider alternatives, you've created the problem for yourself. I don't think it's proper to expect the language to help you out of the hole you dug for yourself.

19 Likes

You could create an extension on the Result type to preserve the type of the failure

extension Result {
    func getFailure() -> Failure? {
        if case .failure(let failure) = self {
            return failure
        }
        return nil
    }
}
let result = await fetchProducts()
if let error = result.getFailure() {
    handleError(error)
    return
}
let products = try! result.get()
// use products...

Though some people don't like to use try!

This is what Result.get is for:

do {
    let products = try await fetchProducts().get()
    let customers = try await fetchCustomers(products: products).get()
    let orders = try await fetchOrders(customers: customers).get()
    print("orders: \(orders)")

} catch {
    print("error: \(error)")
}

func fetchProducts() async -> Result<[Product], Error> { ... }

func fetchCustomers(products: [Product]) async -> Result<[Customer], Error> { ... }

func fetchOrders(customers: [Customer]) async -> Result<[Order], Error> { ... }

Notice how these calls can be chained without any additional nesting for each call.

1 Like

This is a solution that is close to the ergonomics of the one i proposed myself but might be a bit easier to read. I would like to avoid the try! but I think it is a pragmatic solution in this case.

Result where Failure == Error is exactly the same thing as throws. Nobody else needs to suggest it, because you already did, and adopted it, just with a harder to use spelling.

5 Likes

You don't have to nest the success handling.

let products: [Product]
switch result {
    case .success(let p):
        products = p
    case .failure(let error):
        // handle error
        return
}

// handle success

My example did not include a try!.

Have you looked at Result.map/flatMap? You may need to write overloads that support async, but otherwise it could look something like...

let inventoryResult = await fetchProducts()
  .flatMap { await fetchInventory(for: $0) }
2 Likes

As @JuneBash said, this is what map and flatMap are for. If you throw zip into the mix as well and you can do quite a bit. Good stuff about how to use all three together to make declarative data manipulations over at Pointfree in their collection on those three operations: Map, Zip, Flat‑Map

And with async extensions you can basically turn Result into a promise type.

func then<NewSuccess, NewFailure>(onSuccess: (Success) async -> Result<NewSuccess, NewFailure>,
                                  onFailure: (Failure) async -> Result<NewSuccess, NewFailure>) async
    -> Result<NewSuccess, NewFailure> {
    switch self {
    case let .success(value):
        return await onSuccess(value)
    case let .failure(error):
        return await onFailure(error)
    }
}

The syntax is not great now, but if you want to make async calls one by one, try this:

func doSomething() async {
  let productsResult = await fetchProducts()

  let products: [Product]
  switch productsResult {
    case .success(let success): products = success
    case .failure(let error): return await handleError(error)
  }
  
  let productImagesResult = await fetchProductImages(products)
  // ...
}

There ere some discussions about improving of syntax:

guard-catch with typed throws can make such things easier.
Result can be easily transformed to typed throw, if these features come into play. So may be in future soothing like this can be possible:

extension Result {
  func getValueOrThrow() throws Failure -> Success {
    switch self {
    case .success(let success): return success
    case .failure(let error): throw error
    }
  }
}

func doSomething() async {
  guard let products: [Product] = await fetchProducts().getValueOrThrow() catch {
    return await handleError(error) // the error here is of concrete type, the same as in Result
  }  
  let productImagesResult = await fetchProductImages(products)
  // ...
}
1 Like

Do you need to handle the error from each function separately? If not, then my example is the best solution.

1 Like
let inventoryResult = await fetchProducts()
  .flatMap { await fetchInventory(for: $0) }

I am not sure how this code will look if I also want to handle the two error cases?

func then<NewSuccess, NewFailure>(onSuccess: (Success) async -> Result<NewSuccess, NewFailure>,
                                  onFailure: (Failure) async -> Result<NewSuccess, NewFailure>) async
    -> Result<NewSuccess, NewFailure> {
    switch self {
    case let .success(value):
        return await onSuccess(value)
    case let .failure(error):
        return await onFailure(error)
    }
}

If I use this for multiple sequential calls, and I want to bail out if there is an error, I am back to nesting. Or am I missing something here?

This was just an example. Write your own extensions to give yourself the interface you want.

I do not think it is possible to get the interface I want. I think Dmitriy_Ignatyev sums it up very well in their reply.