Daily coverage of WWDC21.
A Swift by Sundell spin-off.

Wrapping completion handlers into async APIs

Published at 20:30 GMT, 10 Jun 2021
Written by: Vincent Pradeilles

If you've been following the WWDC sessions about Swift’s new async/await feature, then you probably already know that most of the asynchronous APIs within Apple’s SDK that used to return their results through completion handlers now offer brand new async alternatives.

The most obvious example being URLSession, where this existing API:

URLSession.dataTask(with: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void)

Now has a more modern, async-based alternative:

URLSession.data(with: URLRequest) async throws -> (Data,URLResponse)

But how does it work when it comes to our own code? Is there a way for us to take our existing, completion handler-based asynchronous APIs and wrap them into new shiny async functions?

From problem to solution

Let’s take a look at an example of one such asynchronous function:

func fetchData(_ completionHandler: @escaping (Result<Data, Error>) -> Void) {
    ...
}

You’ll notice that I didn’t bother to include the implementation of the above function. That’s because we won’t need to change a single thing inside the implementation in order to wrap it in a nice async wrapper. That’s very good news, since that means that the technique that I’m about to show you can be applied to your own code, as well as to closed-source dependencies. 👌

In order to wrap the function fetchData into a new async function, the first step will be to write the signature of this new function:

func fetchData() async -> Result<Data, Error>

Notice how the argument of the completion handler has now become the return value of the new function. Now, let’s try to write the actual implementation for our new function:

func fetchData() async -> Result<Data, Error> {
    // first, we call the original function
    fetchData { result in
        // but how do we return the result?
    }
}

The fist step was easy — we just had to call the original function from within our new one. But then, we quickly ran into a problem — we now need a way to await the execution of the completion handler. However, the completion handler is a regular Swift function, so we cannot use await on it.

Introducing “continuations”

To solve the issue, we‘re going to use a new function that‘s part of Swift 5.5 and that‘s designed specifically for this purpose. Here‘s its signature:

func withCheckedContinuation<T>(_ body: (CheckedContinuation<T, Never>) -> Void) async -> T

Notice how this function takes a completion handler while, at the same time, also being async. It definitely shows that this is indeed the tool that we need to bridge both worlds! So let‘s go ahead and use it:

func fetchData() async -> Result<Data, Error> {
    // we begin by calling withCheckedContinuation
    // since it's an async function, we are allowed to use await
    return await withCheckedContinuation { continuation in
        // then, we call the original fetchData
        fetchData { result in
            // finally, we pass the result to the continuation
            // doing so will resume the awaiting call to withCheckedContinuation
            continuation.resume(returning: result)
        }
    }
}

Something that‘s important to take into account is the very specific contract that we must follow when calling withCheckedContinuation. In the function that we pass as an argument, we must be very careful to call the continuation‘s resume method exactly once. Should we forget to call it, then our code would await forever, and should we call it more than once, then our code would trap and crash as doing so would be considered a serious programming error.

Swift also provides a similar function called withUnsafeContinuation. That alternative works exactly like withCheckedContinuation, but with one difference — multiple calls to resume will not be checked and could lead to undefined behavior.

And that's it! We can now call our new function and enjoy the benefits of an async function at the call site:

let result = await fetchData()

Using Swift's error throwing mechanism

But let's not stop there, because we can actually go even one step further.

The original fetchData function was returning a Result because it had no way of using Swift‘s error throwing mechanism, since it was using a completion handler. Now that we are using an async function, this limitation doesn‘t exist anymore, and we can rework our code as follows:

// notice how the function is now declared as throwing in addition to async
func fetchData() async throws -> Data {
    // instead of withCheckedContinuation, we are now calling withCheckedThrowingContinuation
    return try await withCheckedThrowingContinuation { continuation in
        fetchData { result in
            // depending on the content of result, we either resume with a value or an error
            switch result {
            case .success(let value):
                continuation.resume(returning: value)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

The call site also needs to be updated, as we now need to catch and deal with any potential error that might be thrown:

do {
    let data = try await fetchData()
} catch {
    // deal with the error
}

Conclusion

Swift‘s new async functions are incredibly powerful, and as soon as we begin using them it‘s very hard to go back to the old-fashioned APIs that require completion handlers. Fortunately, as we've seen, Swift provides us with tools to easily wrap these existing APIs into shiny new async functions 🙌

Of course, if your codebase is very large, then you might be concerned about the amount of boilerplate that you‘ll need to write in order to wrap every existing API. But this might actually not be that much of an issue, because the process could be easily automated using a code generation tool, such as Sourcery.

Written by: Vincent Pradeilles
BitriseBitrise

Automatically build, test and distribute your app on every Pull Request, and use a powerful new suite of add-ons to visualize your test results, to ship your app with ease, and to add crash and performance monitoring to your project. Get started for free.