Musings on `Result` and building a command line utility with completion handlers

Collaborating with Paul Hudson is a pleasure but the time difference can be, well, confusing. So when I started putting together an outline about the new command line argument parser for Pragmatic, one of the first things I wrote was a command line utility to tell me what time it was in Bath, UK:

% now bath uk
Bath 4:16:42 PM (GMT+1 United Kingdom Time)

I use CLGeocoder to use whatever terms I enter after the command as the hints to look up places of interests. I grab the first match and pull the time zone from that match (or throw an error if there’s no possible match).

Command-line utilities are not particularly well known for their asynchronous feature support. Because geocodeAddressString does run asynchronously, I use the quick and dirty trick of starting a runloop that executes until the completion handler finishes. I’m basically adapting an async method to sync.

This gave me an opportunity to finally get around to using the new Result type. Restrictions on other projects prevented me from kicking its wheels (or I was already using my own version from way back).

I struggled a little with how to incorporate Result. Here’s an earlier go at this. I use an optional resultto store the result, which is then set in completion scope:

var result: Result<[CLPlacemark], Error>?

CLGeocoder().geocodeAddressString(hint) { placemarks, error in
    switch (placemarks, error) {
    case (_, let error?):
        result = .failure(error)
    case (let placemarks?, _):
        result = .success(placemarks)
    default:
        fatalError("Geocoder error, no results.")
    }
    CFRunLoopStop(CFRunLoopGetCurrent())
}
CFRunLoopRun()

This code bothered me, and not just because I had to test and unwrap result when control returned to the main part of my method. It seems so obvious that Result should have a simple initializer based on the common elements found in legacy completion handlers. This would collapse those arguments to, for example, completion(Result(error, data)). Unless I’m missing something big here, I thought to build my own convenience initializer:

extension Result {
    init(_ success: Success?, _ failure: Failure?) {
        precondition(!(success == nil && failure == nil))
        switch (success, failure) {
        case (let success?, _):
            self = .success(success)
        case (_, .let failure?):
            self = .failure(failure)
        default:
            fatalError("Cannot initialize `Result` without success or failure")
        }
    }
}

That extension allowed me to collapse the code down to this. Notice how I can use the initializer here to eliminate the optional result, simplifying my extraction with get, towards the end. Admittedly, it’s not always easy to come up with an initial value that can be overwritten this way, but here it worked. The handler became two lines long, and processing the result to get my placemark (including all error handling), another two lines:

static func fetchPlaceMark(from hint: String) throws -> CLPlacemark {
    var result: Result<[CLPlacemark], Error> = Result([], nil)

    CLGeocoder().geocodeAddressString(hint) { placemarks, error in
        result = Result(placemarks, error)
        CFRunLoopStop(CFRunLoopGetCurrent())
    }
    CFRunLoopRun()

    let placemarks = try result.get()
    return placemarks[0]
}

I quite like my initializer and wonder why something like that doesn’t already exist unlike init(catching:() -> Success). I’m curious as surely I’ve missed something important. Even in normal completion handlers, I’d imagine using the legacy Error? and Data? optionals would be a common use-case for Result

Let me know.

4 Comments

  • One reason might be on display here in your examples: they behave differently when both success and error cases are non-nil. In the first case you get failure, in the second you get success. I would guess, but don’t have any examples on hand, that legacy code might not be fully consistent when it comes to disallowing this case.

    • I played with things a little and decided that should both be non-nil, the error will always win:

      public extension Result {
          /// Initializes a `Result` from a completion handler's `(data?, error?)`.
          ///
          /// When both data and error are non-nil, `Result` first populates the
          /// `.failure` member over the `success`.
          ///
          /// - Parameters:
          ///   - data: the optional data returned via a completion handler
          ///   - error: the optional error returned via a completion handler
          init(_ data: Success?, _ error: Failure?) {
              precondition(!(data == nil && error == nil))
              switch (data, error) {
              case (_, let failure?): self = .failure(failure)
              case (let success?, _): self = .success(success)
              default:
                  fatalError("Cannot initialize `Result` without success or failure")
              }
          }
      }
      
      • Why there are both `precondition` and `fatalError`? `fatalError` will be called only in unchecked builds and it seems weird because it would still fail but with different message. Since the fatalError can’t be skipped I would remove the `precondition`.

        • `fatalError` is there so it will compile. I have no expectations of that line ever being reached.