modemlooper avatar
About Blog

Advanced Asynchronous Operations with Swift Generics

5 min read
Swift SwfitUI

Asynchronous operations are a powerful way to handle long-running tasks in a structured manner. By leveraging OperationQueue, you can easily track progress and manage dependencies. When we combine these with Generics and the Swift Result type, we can create a highly flexible and robust architecture for complex codebases.

This implementation is based on the system used in the Collect by WeTransfer app, which manages over 50 distinct operations.

  1. Creating a Result-Driven Async Operation Most operations are designed to produce a specific value or, at the very least, catch an error. Swift’s Result<Success, Failure> is the perfect container for this.

Adding Generics

Building on a standard AsyncOperation, we add generics to track both the success value and the error type.


open class AsyncResultOperation<Success, Failure>: AsyncOperation where Failure: Error {
    private(set) public var result: Result<Success, Failure>?
}

Ensuring Results on Finish

To prevent developers from accidentally finishing an operation without setting a result, we override the standard finish() method to throw a fatalError and provide a new finish(with:) method.

final override public func finish() {
    guard !isCancelled else { return super.finish() }
    fatalError("Make use of finish(with:) instead to ensure a result")
}

public func finish(with result: Result<Success, Failure>) {
    self.result = result
    super.finish()
}

Handling Cancellation

Similarly, we enforce that a specific error case is provided when an operation is cancelled.

override open func cancel() {
    fatalError("Make use of cancel(with:) instead to ensure a result")
}

public func cancel(with error: Failure) {
    self.result = .failure(error)
    super.cancel()
}
  1. Example: Unfurling URLs The following example shows an UnfurlURLOperation. It takes a short URL, performs a HEAD request, and returns the final redirected URL.
final class UnfurlURLOperation: AsyncResultOperation<URL, UnfurlURLOperation.Error> {
    enum Error: Swift.Error {
        case canceled
        case missingRedirectURL
        case underlying(error: Swift.Error)
    }

    private let shortURL: URL
    private var dataTask: URLSessionTask?

    init(shortURL: URL) {
        self.shortURL = shortURL
    }

    override func main() {
        var request = URLRequest(url: shortURL)
        request.httpMethod = "HEAD"

        dataTask = URLSession.shared.dataTask(with: request) { [weak self] (_, response, error) in
            if let error = error {
                self?.finish(with: .failure(.underlying(error: error)))
                return
            }

            guard let longURL = response?.url else {
                self?.finish(with: .failure(.missingRedirectURL))
                return
            }

            self?.finish(with: .success(longURL))
        }
        dataTask?.resume()
    }

    override func cancel() {
        dataTask?.cancel()
        cancel(with: .canceled)
    }
}
  1. Strongly Typed Chaining We can take this further by creating Chained Operations, where the output of one task becomes the input for the next.

The Chained Operation Base

We create ChainedAsyncResultOperation, which introduces an input property and a protocol to bridge data between dependencies.

protocol ChainedOperationOutputProviding {
    var output: Any? { get }
}

open class ChainedAsyncResultOperation<Input, Output, Failure>: AsyncResultOperation<Output, Failure>, ChainedOperationOutputProviding where Failure: Swift.Error {
    
    private(set) public var input: Input?

    public init(input: Input? = nil) {
        self.input = input
    }

    var output: Any? {
        return try? result?.get()
    }

    override public final func start() {
        updateInputFromDependencies()
        super.start()
    }

    private func updateInputFromDependencies() {
        guard input == nil else { return }
        input = dependencies.compactMap { ($0 as? ChainedOperationOutputProviding)?.output as? Input }.first
    }
}

Example: Chaining Unfurl and Fetch Title

Imagine we want to unfurl a URL and then fetch the page title. We can create a FetchTitleChainedOperation that accepts a URL as input and outputs a String.

let queue = OperationQueue()
let unfurl = UnfurlURLOperation(shortURL: URL(string: "https://bit.ly/33UDb5L")!)
let fetchTitle = FetchTitleChainedOperation()

// Chain them
fetchTitle.addDependency(unfurl)

queue.addOperations([unfurl, fetchTitle], waitUntilFinished: true)

print("Result: \(fetchTitle.result!)")
// Prints: success("A Swift Blog)

Conclusion By utilizing Swift generics, we’ve moved from simple background tasks to a robust, type-safe pipeline. Chaining operations promotes separation of concerns and creates highly testable, isolated code units.

Related Posts

Getting started with Operations and OperationQueues

Asynchronous operations for writing concurrent solutions

Advanced asynchronous operations by making use of generics