7

Using Swift Result and flatMap

 3 years ago
source link: https://useyourloaf.com/blog/using-swift-result-and-flatmap/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

The Swift Result type is handy way to capture the results of a throwing expression that you need to execute on a background thread. Use flatMap to chain several results together.

The Swift Result Type

Apple added the Result type to the Swift Standard Library in Swift 5. It’s an enum with a success and failure case both of which have an associated value. You can use any type for Success but Failure is constrained to Error:

enum Result<Success, Failure> where Failure : Error {
  case success(Success)
  case failure(Failure)
}

Using Result To Clean Up Completion Handlers

The Result type is often used to clean up the completion handlers of asynch API’s like URLSession. The dataTask(with:completionHandler:) method has a completion handler with three optional parameters:

let task = session,dataTask(with: url) { data, response, error in
  // what combinations of data, response
  // or error are expected here?
}

If you’re unfamiliar with this API it can be hard to know that the completion handler is called either with a non-zero error or with data and a response. You can better represent the success and failure cases with Result:

extension URLSession {
  func load(_ url: URL,
  completionHandler: @escaping (Result<(URLResponse, Data), Error>)
  -> Void) -> URLSessionDataTask {
    let task = ...
    return task
  }
}

Or if we handle the URLResponse for the caller and pass the completion handler data or an error (see Gist for details):

extension URLSession {
  func load(_ url: URL,
  completionHandler: @escaping (Result<Data, Error>) -> Void)
  -> URLSessionDataTask {
    let task = ...
    return task
  }
}

The caller switches over the result to get the data or error:

let task = URLSession.shared.load(url) { result in
  switch result {
  case .success(let data): print(data)
  case .failure(let error): print(error)
  }
}

That’s great but there’s another, easy to miss, use for Result.

Create A Result From A Throwing Expression

You can create a Result from a throwing closure:

// Result<[Country], Error>
let result = Result {
  try JSONDecoder().decode([Country].self, from: data)
}

The Result captures the return value of the expression in the .success value or any thrown error in the .failure value. You can convert a Result back to a throwing expression using get():

let countries = try result.get() // can throw

A Practical Example

That’s all a bit theoretical, here’s a practical example. I want a method on my CountryStore class that loads and decodes country data from a file. You might start by creating a throwing method that runs on the main thread:

final class CountryStore: ObservableObject {
  @Published var countries = [Country]()
  @Published var error: Error?

  func load(_ url: URL) throws {
    let data = try Data(contentsOf: url)
    countries = try PropertyListDecoder().decode([Country].self,
                    from: data)
  }
}

A better way is to make this a non-throwing method that loads and decodes on a background thread:

func load(_ url URL) {
  DispatchQueue.global(qos: .background).async { [weak self] in
    // Non-throwing block to execute
  }
}

Since we can’t throw errors in the block we can wrap the throwing methods with a Result. First to load the data:

let result = Result { try Data(contentsOf: url) }

This gives us a result of type Result<Data, Error>.

Using flatMap

We can chain operations together transforming the result each time with a flatMap (I never remember that it’s flatMap and not flatmap):

.flatMap { data in
  Result {
    try PropertyListDecoder().decode([Country].self, from: data)
  }
}

The flatMap unwraps the second result for us giving us a final result of type Result<[Country], Error>. A plain map would have given us a nested result of type Result<Result<[Country],Error>, Error>.

We then dispatch back to the main thread to update our published properties with the result:

DispatchQueue.main.async {
  switch result {
  case .success(let countries):
    self?.countries = countries
  case .failure(let error):
    self?.error = error
  }
}

We can clean up the unwieldy syntax with an extension on Data:

extension Data {
  static func contentsOf(_ url: URL,
    options: Data.ReadingOptions = []) -> Result<Data,Error> {
    Result { try Data(contentsOf: url, options: options) }
  }
}

Doing the same for PropertyListDecoder:

extension PropertyListDecoder {
  static func decode<T: Decodable>(_ data: Data) -> Result<T,Error> {
    Result { try PropertyListDecoder().decode(T.self, from: data) }
  }
}

Our two throwing methods to load and decode data then reduce to this:

let result: Result<[Country],Error> = Data.contentsOf(url)
            .flatMap(PropertyListDecoder.decode)

The only downside is that we need to help the compiler with the result type. The full method:

final class CountryStore: ObservableObject {
  @Published var countries = [Country]()
  @Published var error: Error?

  func loadStore(_ url: URL) {
    typealias CountryResult = Result<[Country], Error>

    DispatchQueue.global(qos: .background).async { [weak self] in
      let result: CountryResult = Data.contentsOf(url)
                  .flatMap(PropertyListDecoder.decode)
      DispatchQueue.main.async {
        switch result {
        case .success(let countries):
          self?.countries = countries
        case .failure(let error):
          self?.error = error
        }
      }
    }
  }
}

Not exactly a work of art compared to the throwing version but it has more to do. A method to save the store is more compact. The result is of type Result<void,Error> so we only need to look at the failure case:

func saveStore(_ url: URL) {
  DispatchQueue.global(qos: .background).async { [weak self] in
    let result = PropertyListEncoder.encode(self?.countries)
                 .flatMap { Data.write($0, to: url) }
    if case .failure(let error) = result {
      DispatchQueue.main.async {
        self?.error = error
      }
    }
  }
}

See Swift If Case Let for a reminder on how to use if-case-let to avoid a full switch statement when you only care about one case.

Read More


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK