UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

How to use continuations to convert completion handlers into async functions

Paul Hudson    @twostraws   

Updated for Xcode 15

Older Swift code uses completion handlers for notifying us when some work has completed, and sooner or later you’re going to have to use it from an async function – either because you’re using a library someone else created, or because it’s one of your own functions but updating it to async would take a lot of work.

Swift uses continuations to solve this problem, allowing us to create a bridge between older functions with completion handlers and newer async code.

To demonstrate this problem, here’s some code that attempts to fetch some JSON from a web server, decode it into an array of Message structs, then send it back using a completion handler:

struct Message: Decodable, Identifiable {
    let id: Int
    let from: String
    let message: String
}

func fetchMessages(completion: @escaping ([Message]) -> Void) {
    let url = URL(string: "https://hws.dev/user-messages.json")!

    URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data {
            if let messages = try? JSONDecoder().decode([Message].self, from: data) {
                completion(messages)
                return
            }
        }

        completion([])
    }.resume()
}

Although the dataTask(with:) method does run our code on its own thread, this is not an async function in the sense of Swift’s async/await feature, which means it’s going to be messy to integrate into other code that does use modern async Swift.

To fix this, Swift provides us with continuations, which are special objects we pass into the completion handlers as captured values. Once the completion handler fires, we can either return the finished value, throw an error, or send back a Result that can be handled elsewhere.

In the case of fetchMessages(), we want to write a new async function that calls the original, and in its completion handler we’ll return whatever value was sent back:

struct Message: Decodable, Identifiable {
    let id: Int
    let from: String
    let message: String
}

func fetchMessages(completion: @escaping ([Message]) -> Void) {
    let url = URL(string: "https://hws.dev/user-messages.json")!

    URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data {
            if let messages = try? JSONDecoder().decode([Message].self, from: data) {
                completion(messages)
                return
            }
        }

        completion([])
    }.resume()
}

func fetchMessages() async -> [Message] {
    await withCheckedContinuation { continuation in
        fetchMessages { messages in
            continuation.resume(returning: messages)
        }
    }
}

let messages = await fetchMessages()
print("Downloaded \(messages.count) messages.")

Download this as an Xcode project

As you can see, starting a continuation is done using the withCheckedContinuation() function, which passes into itself the continuation we need to work with. It’s called a “checked” continuation because Swift checks that we’re using the continuation correctly, which means abiding by one very simple, very important rule:

Your continuation must be resumed exactly once. Not zero times, and not twice or more times – exactly once.

If you call the checked continuation twice or more, Swift will cause your program to halt – it will just crash. I realize this sounds bad, but when the alternative is to have some bizarre, unpredictable behavior, crashing doesn’t sound so bad.

On the other hand, if you fail to resume the continuation at all, Swift will print out a large warning in your debug log similar to this: “SWIFT TASK CONTINUATION MISUSE: fetchMessages() leaked its continuation!” This is because you’re leaving the task suspended, causing any resources it’s using to be held indefinitely.

You might think these are easy mistakes to avoid, but in practice they can occur in all sorts of places if you aren’t careful.

For example, in our original fetchMessages() method we used this:

if let data = data {
    if let messages = try? JSONDecoder().decode([Message].self, from: data) {
        completion(messages)
        return
    }
}

completion([])

That checks for data coming back, and checks that it can be decoded correctly, before completing and returning, but if either of those two checks fail then the completion handler is called with an empty array – no matter what happens, the completion handler gets called.

But what if we had written something different? See if you can spot the problem with this alternative:

if let data = data {
    if let messages = try? JSONDecoder().decode([Message].self, from: data) {
        completion(messages)
    }
} else {
    completion([])
}

That attempts to decode the JSON into a Message array and send back the result using the completion handler, or send back an empty array if nothing came back from the server. However, it has a mistake that will cause problems with continuations: if some valid data comes back but can’t be decoded into an array of messages, the completion handler will never be called and our continuation will be leaked.

These two code samples are fairly similar, which shows how important it is to be careful with your continuations. However, if you have checked your code carefully and you’re sure it is correct, you can if you want replace the withCheckedContinuation() function with a call to withUnsafeContinuation(), which works exactly the same way but doesn’t add the runtime cost of checking you’ve used the continuation correctly.

I say you can do this if you want, but I’m dubious about the benefit. It’s easy to say “I know my code is safe, go for it!” but I’d be wary about moving across to unsafe code unless you had profiled your code using Instruments and were quite sure Swift’s extra checks were causing a performance problem.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.9/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.