71

The power of Result types in Swift

 5 years ago
source link: https://www.tuicool.com/articles/hit/2aIRN3U
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.

One big benefit of Swift's type system is how it lets us eliminate a lot of ambiguity when it comes to handling values and results of various operations. With features like generics and associated enum values, we can easily create types that let us leverage the compiler to make sure that we're handling values and results in a correct way.

An example of such a type is the Result type - and while it's not (yet) built into the standard library, it's a type which is very commonly seen in many different Swift projects. This week, let's explore various versions of such a result type, and some of the cool things it lets us do when combined with some of Swift's language features.

The problem

When performing many kinds of operations, it's very common to have two distinct outcomes - success and failure . In Objective-C, the only really practical way to model those two outcomes was to include both a value and an error in the result. However, when translated into Swift, the problem with that approach becomes quite evident - since both the value and the error have to be optionals:

func load(then handler: @escaping (Data?, Error?) -> Void) {
    ...
}

The problem is that handling the result of the above load method becomes quite tricky. Even if the error parameter is nil , there's no compile-time guarantee that the data we're looking for is actually there - it might be nil as well for all we know, which would put our code in a bit of a strange state.

Separate states

Using a Result type addresses this problem by turning each result into two separate states, by using an enum containing a case for each state - one for success and one for failure :

enum Result<Value> {
    case success(Value)
    case failure(Error)
}

By making our result type generic, it can easily be reused in many different contexts, while still retaining full type safety. If we now update our load method to use the above result type, we can see that things become a lot more clear:

func load(then handler: @escaping (Result<Data>) -> Void) {
    ...
}

Not only does using a Result type improve the compile-time safety of our code, it also encourages us to always add proper error handling whenever we are calling an API that produces a result value, like this:

load { result in
    switch result {
    case .success(let data):
        // Handle the loaded data
    case .failure(let error):
        // Error handling
    }
}

We have now both made our code more clear, and have also removed a source of ambiguity, leading to an API that is much nicer to use :+1:.

Typed errors

In terms of type safety, we can still take things further though. In our previous iteration, the failure case of our Result enum contained an error value that could be of any type conforming to Swift's Error protocol. While that gives us a lot of flexibility, it does make it hard to know exactly what errors that might be encountered.

One way to solve that problem is to make the associated error value a generic type as well:

enum Result<Value, Error: Swift.Error> {
    case success(Value)
    case failure(Error)
}

That way, we are now required to specify what type of error that the API user can expect. As an example, let's update our load method from before to now use our new result type with strongly typed errors:

typealias Handler = (Result<Data, LoadingError>) -> Void

func load(then handler: @escaping Handler) {
    ...
}

It can be argued that using strongly typed errors like this sort of goes against Swift's current error handling model - which doesn't include typed (also known as checked ) errors, in contrast to languages like Java. However, adding that extra type information to our result type does have some nice benefits - for example, it lets us specifically handle all possible errors at the call site, like this:

load { [weak self] result in
    switch result {
    case .success(let data):
        self?.handle(data)
    case .failure(let error):
        // Since we now know the type of 'error', we can easily
        // switch on it to perform much better error handling
        // for each possible type of error.
        switch error {
        case .networkUnavailable:
            self?.showErrorView(withMessage: .offline)
        case .timedOut:
            self?.showErrorView(withMessage: .timedOut)
        case .invalidStatusCode(let code):
            self?.showErrorView(withMessage: .statusCode(code))
        }
    }
}

Doing error handling like we do above might seem like overkill, but "forcing" ourselves into the habit of handling errors in such a finely grained kind of way can often produce a much nicer user experience - since users are actually informed about what went wrong instead of just seeing a generic error screen, and we can even add appropriate actions for each error.

However, given Swift's current error system, it's not always practical (or even possible) to get a strongly typed, predictable error out of every operation. Sometimes we need to use underlying APIs and systems that could produce any error, so we need some way to be able to tell the type system that our result type can contain any error as well.

Thankfully, Swift provides a very simple way of doing just that - using our good old friend NSError from Objective-C. Any Swift error can be automatically converted into an NSError , without the need for optional type casting. What's even better, is that we can even tell Swift to convert any error thrown within a do clause to an NSError , making it simple to pass it along to a completion handler:

class ImageProcessor {
    typealias Handler = (Result<UIImage, NSError>) -> Void

    func process(_ image: UIImage, then handler: @escaping Handler) {
        do {
            // Any error can be thrown here
            var image = try transformer.transform(image)
            image = try filter.apply(to: image)
            handler(.success(image))
        } catch let error as NSError {
            // When using 'as NSError', Swift will automatically
            // convert any thrown error into an NSError instance
            handler(.failure(error))
        }
    }
}

As you can see above, we'll always get an NSError in our catch block, regardless of what error that was thrown above. While it's usually a good idea to provide a unified error API at the top level of a framework or module, doing the above can help us reduce boilerplate when it's not really important that we handle any specific errors.

Throwing

Sometimes we don't really want to switch on a result, but rather hook it directly into Swift's do, try, catch error handling model. The good news is that since we now have a dedicated type for results, we can easily extend it to add convenience APIs - like this one that enables us to either return a value or throw using a single call:

extension Result {
    func resolve() throws -> Value {
        switch self {
        case .success(let value):
            return value
        case .failure(let error):
            throw error
        }
    }
}

The above extension can really become useful for things like tests, when we don't really want to add any code branches or conditionals. Here's an example in which we're testing a SearchResultsLoader by using a mocked, synchronous, network engine - and by using our new resolve method we can keep all assertions and verifications at the top level of our test, like this:

class SearchResultsLoaderTests: XCTestCase {
    func testLoadingSingleResult() throws {
        let engine = NetworkEngineMock.makeForSearchResults(named: ["Query"])
        let loader = SearchResultsLoader(networkEngine: engine)
        var result: Result<[SearchResult], SearchResultsLoader.Error>?

        loader.loadResults(matching: "query") {
            result = $0
        }

        let searchResults = try result?.resolve()
        XCTAssertEqual(searchResults?.count, 1)
        XCTAssertEqual(searchResults?.first?.name, "Query")
    }
}

Decoding

We can also keep adding more extensions for other common operations. For example, if our app deals a lot with JSON, we could use a same type constraint to enable any Result value carrying Data to be directly decoded - by adding the following extension:

extension Result where Value == Data {
    func decoded<T: Decodable>() throws -> T {
        let decoder = JSONDecoder()
        let data = try resolve()
        return try decoder.decode(T.self, from: data)
    }
}

With the above in place, we can now easily decode any loaded data, or throw if an error was encountered - either in the loading operation itself or while decoding:

load { [weak self] result in
    do {
        let user = try result.decoded() as User
        self?.userDidLoad(user)
    } catch {
        self?.handle(error)
    }
}

Pretty neat! :+1:

Conclusion

Using a Result type can be a great way to reduce ambiguity when dealing with values and results of asynchronous operations. By adding convenience APIs using extensions we can also reduce boilerplate and make it easier to perform common operations when working with results, all while retaining full type safety.

Whether or not to require errors to be strongly typed as well continues to be a debate within the community, and it's something I personally go back and forth a lot on too. On one hand, I like how it makes it simpler to add more thorough error handling, but on the other hand it feels a bit like fighting the system - since Swift doesn't yet support strongly typed errors as first class citizens.

Another thing that can be a bit of a challenge with result types in their current state is when multiple frameworks or modules each define their own result type, and we end up having to convert between them in our app code. One solution to this problem would be to actually add a Result type to the standard library, that all frameworks and apps could use to handle results in a uniform way.

What do you think? Do you use result types in your projects and is it something you'd like to see added to the standard library? Do you prefer typed or untyped errors? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell .

Thanks for reading! :rocket:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK