Result-oriented Swift

Image credit: Dan Boss

Here’s a rundown of why you should be using the awesome power of the Swift enum to represent the results of your failable operations. Hold tight, this is going to get Schwifty.

The problem

If you’re writing an app which makes network or database calls, chances are you have something like this:

import BananaKit
import FruitNetworking

struct BananaDataSource {
    
    typealias FetchCompletion = (banana: Banana?, error: NSError?) -> Void
    
    static func fetchBananaWithURL(URLString: String, completion: FetchCompletion) {
        guard let URL = NSURL(string: URLString) else {
            completion(banana: nil, error: self.invalidURLError(URLString))
            return
        }
        
        let requestOperation = FruitHTTPRequestOperation(request: NSURLRequest(URL: URL))
        requestOperation.responseSerializer = FruitJSONResponseSerializer()
        
        requestOperation.setCompletionBlockWithSuccess({ operation, responseObject in
            guard let banana = BananaModelFactory.bananaWithDict(responseObject) else {
                completion(banana: nil, error: self.bananaDeserialisingError())
                return
            }
            completion(banana: banana, error: nil)
        }, failure: { operation, error in
            completion(banana: nil, error: error)
        })
        
        requestOperation.start()
    }
    
    //MARK: Errors
    
    static let errorDomain = "au.com.domain.BananaDataSource"
    static let deserialisingErrorCode = 0
    static let invalidURLErrorCode = 1
    
    private static func bananaDeserialisingError() -> NSError {
        return NSError(domain: self.errorDomain, code: self.deserialisingErrorCode, userInfo: [NSLocalizedDescriptionKey: "BananaDataSource couldn't deserialise"])
    }
    
    private static func invalidURLError(URLString: String) -> NSError {
        return NSError(domain: self.errorDomain, code: self.invalidURLErrorCode, userInfo: [NSLocalizedDescriptionKey: "invalid URL provided to BananaDataSource \(URLString)"])
    }
}

with a consumer that looks like:

func getBanana() {
    BananaDataSource.fetchBananaWithURL(self.currentBananaURLString) { banana, error in
        if let error = error where error.domain == BananaDataSource.errorDomain {
            ErrorLogging.logError(error)
            if error.code == BananaDataSource.invalidURLErrorCode {
                self.flagBrokenURL(self.currentBananaURLString)
            }
        } else if let error = error {
            ErrorLogging.logError(error)
            self.retryGetBanana()
        } else if let banana = banana {
            self.peelBanana(banana)
        } else {
            //Impossible, right?
        }
    }
}

It does the job, but it’s far from perfect:

  • We’ve assumed in writing our completion that `banana` and `error` are mutually exclusive, but there’s nothing in the completion signature to tell us that. To know for sure that it’s impossible to get both an `error` and a `banana` back (or neither), we need to look at the `BananaDataSource` code.
  • We want to flag the URL as broken if we pick up an `invalidURLErrorCode`, but it wouldn’t be sensible to pack the offending URL in the `NSError` and then extract it later, so we use `self.currentBananaURLString`. But what if `currentBananaURLString` has changed in between making the request and receiving the response. We’ve inadvertently reported the incorrect URL.
  • We know about `invalidURLErrorCode` and we’re handling it accordingly, but what if someone maintaining `BananaDataSource` introduces a new error and forgets to consider how our consumer might handle that?
  • It’s implicit in our handling that if the `error.domain != BananaDataSource.errorDomain`, we’re dealing with a networking error of some sort, but that’s not obvious to anyone reading the code for the first time.

Enter `Result`

One solution to these problems is to use an `enum` to represent all of the possible completion states of the `fetchBanana` operation. Swift enums are a powerful construct in many ways but here we’ll specifically use their ability to hold associated values.

I Antitypical Result framework for this task. It provides much excellent functionality to help reduce boilerplate and make Result-y code more elegant and expressive, but you can basically reduce it down to a few lines:

enum Result<Value, Error> {
    case Success(Value)
    case Failure(Error)
}

`Result` is just an `enum` with two generic parameters. So when I define a `Result` type of, say, `Result<Banana, NSError>` what I’m doing is defining a contract for any operation with that result. I’m saying that the operation must return either a `.Success` containing a `Banana`, or a `.Failure` containing an `NSError`. There’s no middle ground – if you try to `return .Success(nil)` or `return .Failure(somethingThatIsntAnNSError)` you’ll get a compile error. This allows us to express the mutual exclusivity of `Banana` and `NSError` via the type system. Where before we had a piece of knowledge that was shared between two objects and only checked at runtime, we now have a compile-time enforcement.

We get a similar enforcement in the code which consumes and handles the Result`:

typealias BananaResult = Result<Banana, NSError>

func fetchBanana() -> BananaResult { ... }

...

switch fetchBanana() {
    case let .Success(banana):
        peelBanana(banana)
    case let .Failure(error):
        logError(error)
}

In the block of code above we’ve elegantly and exhaustively handled all possible outcomes of `fetchBanana`. The `switch` statement above doesn’t have a `default` statement because the compiler can verify for us that we’ve handled all possible cases.

Fixing the DataSource

So refactoring the `BananaDataSource` example above to use `Result`, we get something like this:

import BananaKit
import FruitNetworking
import Result

struct BananaDataSource {
    
    enum FetchError: ErrorType {
        case InvalidURL(URLString: String)
        case NetworkFailed(error: NSError)
        case DeserialisingFailed
    }
    typealias FetchResult = Result<Banana, FetchError>
    typealias FetchCompletion = (result: FetchResult) -> Void
    
    static func fetchBananaWithURL(URLString: String, completion: FetchCompletion) {
        guard let URL = NSURL(string: URLString) else {
            completion(result: .Failure(.InvalidURL(URLString: URLString)))
            return
        }
        
        let requestOperation = FruitHTTPRequestOperation(request: NSURLRequest(URL: URL))
        requestOperation.responseSerializer = FruitJSONResponseSerializer()
        
        requestOperation.setCompletionBlockWithSuccess({ operation, responseObject in
            guard let banana = BananaModelFactory.bananaWithDict(responseObject) else {
                completion(result: .Failure(.DeserialisingFailed))
                return
            }
            completion(result: .Success(banana))
        }, failure: { operation, error in
            completion(result: .Failure(.NetworkFailed(error: error)))
        })
        
        requestOperation.start()
    }
}

Note that the `Error` type for `FetchResult` is itself an `enum`. By nesting an error `enum` within the result `enum`, we can represent our failure outcomes expressively and concisely. For example, the invalid URL `guard` block:

guard let URL = NSURL(string: URLString) else {
    let result = FetchError.Failure(.InvalidURL(URLString: URLString))
    completion(result: result)
    return
}

…we’ve packed the cause of the problem in a statically-typed, clearly named structure.

There’s not much we can do about the fact that the `FruitNetworking` library will give us an `NSError` on failure, but we can use the same approach of packing the error in a `.Failure(.NetworkFailed(error: error))` to help the consumer differentiate between `BananaDataSource`’s error states and those of the networking library.

Fixing the consumer

If we refactor `getBanana` accordingly, we see more benefits:

func getBanana() {
    BananaDataSource.fetchBananaWithURL(self.currentBananaURLString) { result in
        switch result {
        case let .Success(banana):
            self.peelBanana(banana)
        case let .Failure(.InvalidURL(URLString)):
            ErrorLogging.logErrorWithMessage("invalid URL provided to BananaDataSource \(URLString)")
            self.flagBrokenURL(URLString)
        case let .Failure(.NetworkFailed(error)):
            ErrorLogging.logErrorWithMessage("BananaDataSource failed to fetch with error: \(error)")
        case .Failure(.DeserialisingFailed):
            ErrorLogging.logErrorWithMessage("BananaDataSource failed to deserialise")
        }
    }
}
  • All of the outcomes of `fetchBananaWithURL` are handled in a single exhaustive `switch` statement. The `Result` and `FetchError` cases serve to comment those outcomes – the happy `.Success` path is more visible and the cause of each of the `.Failure` cases is now obvious.
  • Associated values for each case are statically typed, and we’ve dropped the error-prone use of `self.flagBrokenURL(self.currentBananaURLString)`.
  • If someone were to add an extra case to `BananaDataSource.FetchError`, for example, we’d get a compiler error in `getBanana` until we added appropriate handling of that case.

Extending `FetchError`

The code above doesn’t necessarily give us all of the information we need about the causes of each failure, but fortunately `enum` cases can contain multiple associated values:

enum FetchError: ErrorType {
    case InvalidURL(URLString: String)
    case NetworkFailed(URLString: String, error: NSError)
    case DeserialisingFailed(URLString: String)
}
...

let deserialiseFailure = FetchError.Failure(.DeserialisingFailed(URLString: URLString))
let networkFailure = FetchError.Failure(.NetworkFailed(URLString: URLString, error: error))
func getBanana() {
    BananaDataSource.fetchBananaWithURL(self.currentBananaURLString) { result in
        switch result {
        case let .Success(banana):
            self.peelBanana(banana)
        case let .Failure(.InvalidURL(URLString)):
            ErrorLogging.logErrorWithMessage("invalid URL provided to BananaDataSource \(URLString)")
            self.flagBrokenURL(URLString)
        case let .Failure(.NetworkFailed(URLString, error)):
            ErrorLogging.logErrorWithMessage("BananaDataSource failed to fetch for \(URLString), error: \(error)")
        case .Failure(.DeserialisingFailed(URLString)):
            ErrorLogging.logErrorWithMessage("BananaDataSource failed to deserialise for \(URLString)")
        }
    }
}

Further reading

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s