Write convenient APIs for networking by extending the URLSession and Result types

As most of you must know, in WWDC 2019, Apple released its functional reactive framework named Combine. I know the title says this article is about extending the URLSession and Result types built-in in Swift; I am bringing Combine to the table because I wanted to let you all know where the idea came up.

The first thing that caught my attention was "how easy" it is to read the code chained in Publishers and how pretty it looks. To give you a better idea of what I mean, let's see an example using the URLSession API with Combine.

urlSession
    .dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: Response<User>.self, decoder: JSONDecoder())
    .map(\.results)
    .eraseToAnyPublisher()

That piece of code is pretty declarative. As soon as you read it, you can immediately catch what the code is doing.

  • Performs a request with the given URL.
  • Maps the \.data (using key-paths).
  • Decodes the data into a Decodable model.
  • Maps and gets the \.results (using key-paths also).
  • Returns an erased Publisher.

Combine is available for the following deployment targets:

  • iOS 13.0+
  • macOS 10.15+
  • Mac Catalyst 13.0+
  • tvOS 13.0+
  • watchOS 6.0+

If you are still supporting lower deployment targets in your app/framework or haven't had the chance to migrate your code to use Combine, it means, you are still using the closures in the URLSession API.

What I am seeking to achieve here is to extend the Swift types so we can have a similar "look-and-feel" to using Combine. It won't be that astonishing, but I think we can make it better.

Extending URLSession

The URLSession API provides a function with a closure that takes another function that receives a tuple of 3 optional types: (Data?, URLResponse?, Error?).

func dataTask(
    with url: URL,
    completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionDataTask

I presume this is because of the interoperability between Swift and Obj-C.

- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)
                     urlcompletionHandler:(void (^)(
                                                    NSData * _Nullable data,
                                                    NSURLResponse * _Nullable response,
                                                    NSError * _Nullable error)
                                                   )completionHandler;

Publishers are "functional data structures" that we can categorize as Functors and Monads, and this means that Publishers can do at least map and flatMap operations. And also, Publishers can deal with errors.

The closest and most convenient type I can think about for achieving the same thing - in terms of "mapping" operations - is the Result type which is also a Functor and a Monad, and it is meant to handle errors.

The first thing we are going to do is get rid of the unnecessary optional parts in the callback, and in doing so, the Result (Sum) type will replace the "tuple" (Product).

To have a better understanding of what Sum and Product are and the thinking behind the reasoning in this article, you can read Algebraic types in the Swift type system.

This being said, let's extend URLSession to use Result in the closure function.

extension URLSession {
    func dataTask(
        with url: URL,
        completionHandler: @escaping (Result<(data: Data, response: URLResponse), Error>) -> Void
    ) -> URLSessionDataTask {
        dataTask(with: url) { data, response, error in
            if let error = error {
                completionHandler(.failure(error))
            } else if let data = data, let response = response {
                completionHandler(.success((data: data, response: response)))
            }
        }
    }
}

Technically speaking, now we can handle the response by looking at either the .success or the .failure.

urlSession
    .dataTask(with: url) { result in
        switch result {
        case let .success((data, response)):
            // success
        case let .failure(error):
            // failure
        }
    }
    .resume()

Extending Result

The Combine extension in the built-in API that allows the mapping by using key-paths is defined as:

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension Publisher {
    func map<T>(_ keyPath: KeyPath<Self.Output, T>) -> Publishers.MapKeyPath<Self, T>
}

In the context of the .dataTaskPublisher(for:) function Self.Output would be equivalent to:

(data: Data, response: URLResponse)

So, we can define a pretty similar API for the Result type in the following way:

extension Result where Success == (data: Data, response: URLResponse) {
    func map<T>(
        _ keyPath: KeyPath<Success, T>
    ) -> Result<T, Failure> {
        switch self {
        case let .success(dataResponse):
            return .success(dataResponse[keyPath: keyPath])
        case let .failure(error):
            return .failure(error)
        }
    }
}

At this point, we should be able to at least map the data response in a similar way that Combine does.

urlSession
    .dataTask(with: url) { result in
        _ = result
            .map(\.data)
    }
    .resume()

Adding support for decoding

The next thing we are trying to achieve is enabling the .decode transformation. If we look at the existing extension in Combine, we got the following:

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension Publisher {
    func decode<Item, Coder>(
        type: Item.Type,
        decoder: Coder
    ) -> Publishers.Decode<Self, Item, Coder>
    where Item: Decodable, Coder: TopLevelDecoder, Self.Output == Coder.Input
}

Who gets the work done in this built-in implementation is the value/instance that conforms to the TopLevelDecoder protocol, which commonly is a JSONDecoder. Let's see the TopLevelDecoder definition:

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol TopLevelDecoder {
    /// The type this decoder accepts.
    associatedtype Input
    /// Decodes an instance of the indicated type.
    func decode<T>(
        _ type: T.Type,
        from: Self.Input
    ) throws -> T where T: Decodable
}

So, let's come up with a similar approach. Because we want to avoid conflict with the Combine definitions, let's create a different one for our non-Combine implementation.

protocol HigherLevelDecoder {
    func decode<T: Decodable>(
        _ type: T.Type,
        from data: Data
    ) throws -> T
}

extension JSONDecoder: HigherLevelDecoder {}

Then, we can proceed with the definition of the Result extension constrained to Data values for decoding.

extension Result where Success == Data {
    func decode<Item, Decoder>(
        type: Item.Type,
        decoder: Decoder
    ) -> Result<Item, Error>
    where Item: Decodable, Decoder: HigherLevelDecoder {
        do {
            let data = try get()
            let decoded = try decoder.decode(type, from: data)
            return .success(decoded)
        } catch {
            return .failure(error)
        }
    }
}

With this extension, we can have a similar pipeline for a response "mapping" and "decoding" to the one with Combine.

urlSession
    .dataTask(with: url) { result in
        _ = result
            .map(\.data)
            .decode(type: Response<User>.self, decoder: JSONDecoder())
            .map(\.results)
    }
    .resume()

Extending support for Concurrency - async/await

With Xcode 13.2 and Swift 5.5, Apple made available the Concurrency API, allowing developers to write async code in a - kind of - sync manner by using an async/await syntax.

The existing built-in functions in the Concurrency API for URLSession implement an async-throws syntax. However, I like the possibilities I get as a developer when working with a monad. Because of this, I wanted to go beyond and extend the URLSession to return an async-Result instead.

extension URLSession {
    func data(
        from url: URL,
        delegate: URLSessionTaskDelegate? = nil
    ) async -> Result<(data: Data, response: URLResponse), Error> {
        await withCheckedContinuation { continuation in
            dataTask(with: url) { result in
                continuation
                    .resume(returning: result)
            }
            .resume()
        }
    }
}

And great!, That's all. We can now also replicate the same pipeline by using Concurrency.

Task {
    _ = await urlSession
        .data(from: url)
        .map(\.data)
        .decode(type: Response<User>.self, decoder: JSONDecoder())
        .map(\.results)
}

TL;DR

  • By writing convenient APIs, we can extend the functionality of our code and get a prettier and easier code to work with.
  • For production apps/frameworks, even though you have to support older deployment targets, you still can provide a "similar" API to the one that Combine provides by simply writing Swift extensions.
  • The Result type build-in in Swift, by being a Functor, perfectly knows how to do map transformations and also knows how to flip between Success and Failure cases (it is a Sum type). As developers, we can always take advantage of it.