Skip to Content
Course content

68: Continuations for Bridging Callback APIs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was helping a teammate migrate a massive legacy codebase to Swift's new concurrency model. He had this one specific service—a custom network wrapper written back in the Swift 3 days—that used nested completion handlers for everything. He'd spent the whole morning trying to wrap it in a Task block, but he ended up with this "pyramid of doom" inside his async functions, defeating the whole purpose of using await. He looked at me, genuinely frustrated, and asked why he still had to deal with callbacks when he was already inside an asynchronous context.

The problem is that async/await is a language feature, but many of the libraries we use (including some older Apple frameworks) were written long before it existed. You can't just put await in front of a function that takes a closure; the compiler will just stare at you blankly. To fix this, we use "continuations." Think of a continuation as a way to tell Swift: "Pause this function right here, go do some work in this old callback-style API, and let me know when you're done so I can resume where I left off."

Turning Callbacks into Awaitable Calls

The most common tool for this is withCheckedContinuation. When you call this function, Swift suspends the current task and hands you a continuation object. Your job is to pass that object into your legacy callback and call resume(returning:) exactly once when the data comes back.

// Imagine this is some old SDK you can't change
struct LegacyImageDownloader {
    func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void) {
        // Old school networking...
    }
}

// Here is how we bridge it
extension LegacyImageDownloader {
    func fetchImage(url: URL) async -> UIImage? {
        await withCheckedContinuation { continuation in
            // We call the old API and use the callback to resume the async function
            self.fetchImage(url: url) { image in
                continuation.resume(returning: image)
            }
        }
    }
}

Notice how the calling code now looks clean: let image = await downloader.fetchImage(url: url). The complexity is hidden inside the wrapper. I usually recommend adding these wrappers in an extension so you don't pollute the original service logic.

Dealing with Errors and the "Exactly Once" Rule

Most real-world APIs don't just return a value; they return a Result or an error. In those cases, you use withCheckedThrowingContinuation. This version allows you to call resume(throwing:) if something goes wrong, which seamlessly integrates with Swift's do-catch blocks.

func fetchUserAccount(id: String) async throws -> Account {
    try await withCheckedThrowingContinuation { continuation in
        legacyAPI.getUser(id: id) { result in
            switch result {
            case .success(let account):
                continuation.resume(returning: account)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Now, here is the part where you have to be careful. The "Checked" part of withCheckedContinuation is there for a reason. Swift enforces a strict rule: you must resume the continuation exactly once. If you call resume twice, your app will crash. If you never call it (perhaps because of a guard statement that returns early without resuming), the task will hang forever, leaking memory. I've seen this happen in production more times than I'd like to admit—usually because a developer forgot to call resume inside an error branch.




📋 Practical Task

Exercise: Converting the Legacy Location Service to Async/Await

You have been handed a LegacyLocationManager that uses a completion handler to fetch the user's current coordinates. Your task is to create an async wrapper for it so it can be used in a modern Swift concurrency pipeline.

import Foundation

struct Coordinates {
    let latitude: Double
    let longitude: Double
}

enum LocationError: Error {
    case permissionDenied
    case timeout
}

// DO NOT MODIFY THIS CLASS
class LegacyLocationManager {
    func getCurrentLocation(completion: @escaping (Result<Coordinates, LocationError>) -> Void) {
        // Simulating a network delay
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            let success = Bool.random()
            if success {
                completion(.success(Coordinates(latitude: 34.0522, longitude: -118.2437)))
            } else {
                completion(.failure(.timeout))
            }
        }
    }
}

// YOUR TASK:
// 1. Create an extension on LegacyLocationManager.
// 2. Implement an async function called 'fetchLocation()' that returns Coordinates.
// 3. Use withCheckedThrowingContinuation to bridge the callback to async/await.

extension LegacyLocationManager {
    // Write your code here
}

// Test your implementation:
Task {
    let manager = LegacyLocationManager()
    do {
        let coords = try await manager.fetchLocation()
        print("Location found: \(coords.latitude), \(coords.longitude)")
    } catch {
        print("Error fetching location: \(error)")
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.