Wrapping existing asynchronous code in async/await in Swift

Published on: April 24, 2022

Swift’s async/await feature is an amazing way to improve the readability of asynchronous code on iOS 13 and newer. For new projects, this means that we can write more expressive, more readable, and easier to debug asynchronous code that reads very similar to synchronous code. Unfortunately, for some of us adopting async/await means that we might need to make pretty significant changes to our codebase if it’s asynchronous API is currently based on functions with completion handlers.

Luckily, we can leverage some of Swift’s built-in mechanisms to provide a lightweight wrapper around traditional asynchronous code to bring it into the async/await world. In this post, I’ll explore one of the options we have to convert existing, callback based, asynchronous code into functions that are marked with async and work with async/await.

Converting a callback based function to async/await

Callback based functions can come in various shapes and forms. However, most of them will look somewhat like the following example:

func validToken(_ completion: @escaping (Result<Token, Error>) -> Void) {
    let url = URL(string: "https://api.internet.com/token")!
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else {
            completion(.failure(error!))
        }

        do {
            let decoder = JSONDecoder()
            let token = try decoder.decode(Token.self, from: data)
            completion(.success(token))
        } catch {
            completion(.failure(error))
        }
    }
}

The example above is a very simplified version of what a validToken(_:) method might look like. The point is that the function takes a completion closure, and it has a couple of spots where this completion closure is called with the result of our attempt to obtain a valid token.

💡 Tip: if you want to learn more about what @escaping is and does, take a look at this post.

The easiest way to make our validToken function available as an async function, is to write a second version of it that’s marked async throws and returns Token. Here’s what the method signature looks like:

func validToken() async throws -> Token {
    // ...
}

This method signature looks a lot cleaner than we had before, but that’s entirely expected. The tricky part now is to somehow leverage our existing callback based function, and use the Result<Token, Error> that’s passed to the completion as a basis for what we want to return from our new async validToken.

To do this, we can leverage a mechanism called continuations. There are several kinds of continuations available to us:

  • withCheckedThrowingContinuation
  • withCheckedContinuation
  • withUnsafeThrowingContinuation
  • withUnsafeContinuation

As you can see, we have checked and unsafe continuations. To learn more about the differences between these two different kinds of continuations, take a look at this post. You’ll also notice that we have throwing and non-throwing versions of continuations. These are useful for exactly the situations you might expect. If the function you’re converting to async/await can fail, use a throwing continuation. If the function will always call your callback with a success value, use a regular continuation.

Before I explain more, here’s how the finished validToken looks when using a checked continuation:

func validToken() async throws -> Token {
    return try await withCheckedThrowingContinuation { continuation in
        validToken { result in
            switch result {
            case .success(let token):
                continuation.resume(returning: token)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Again, if you want to learn more about the difference between unsafe and checked continuations, take a look at this post.

Notice how I can return try await withChecked.... The return type for my continuation will be the type of object that I pass it in the resume(returning:) method call. Because the validToken version that takes a callback calls my callback with a Result<Token, Error>, Swift knows that the success case of the result argument is a Token, hence the return type for withCheckedThrowingContinuation is Token because that’s the type of object passed to resume(returning:).

The withCheckedThrowingContinuation and its counterparts are all async functions that will suspend until the resume function on the continuation object is called. This continuation object is created by the with*Continuation function, and it’s up to you to make use of it to (eventually) resume execution. Not doing this will cause your method to be suspended forever since the continuation never produces a result.

The closure that you pass to the with*Continuation function is executed immediately which means that the callback based version of validToken is called right away. Once we call resume, the caller of our async validToken function will immediately be moved out of the suspended state it was in, and it will be able to resume execution.

Because my Result can contain an Error, I also need to check for the failure case and call resume(throwing:) if I want the async validToken function to throw an error.

The code above is pretty verbose, and the Swift team recognized that the pattern above might be a pretty common one so they provided a third version of resume that accepts a Result object. Here’s how we can use that:

func validToken() async throws -> Token {
    return try await withCheckedThrowingContinuation { continuation in
        validToken { result in
            continuation.resume(with: result)
        }
    }
}

Much cleaner.

There are two important things to keep in mind when you’re working with continuations:

  1. You can only resume a continuation once
  2. You are responsible for calling resume on your continuation from within your continuation closure. Not doing this will cause the caller of your function to be await-ing a result forever.

It’s also good to realize that all four different with*Continuation functions make use of the same rules, with the exception of whether they can throw an error or not. Other rules are completely identical between

Summary

In this post, you saw how you can take a function that takes a callback, and convert it to an async function by wrapping it in a continuation. You learned that there are different kinds of continuations, and how they can be used.

Continuations are an awesome way to bridge your existing code into async/await without rewriting all of your code at once. I’ve personally leveraged continuations to slowly but surely migrate large portions of code into async/await one layer at a time. Being able to write intermediate layers that support async/await between, for example, my view models and networking without having to completely rewrite networking first is awesome.

Overall, I think continuations provide a really simple and elegant API for converting existing callback based functions into async/await.