← All shorts
58Swift 5.5+2 min read

withCheckedThrowingContinuation: Bridging Callback APIs

Continuations wrap a callback-based API in a single async call, as long as the callback is invoked exactly once.

Plenty of frameworks still hand you a completion-handler API with no async overload — CoreLocation delegates, third-party SDKs, legacy internal networking layers. Rewriting them isn't always an option, but calling them from async code still needs to work.

#The old way

func requestReview(for orderID: String, completion: @escaping (Bool) -> Void) {
    LegacyReviewSDK.shared.submit(orderID: orderID) { approved in
        completion(approved)
    }
}

requestReview(for: "order-42") { approved in
    print("Approved: \(approved)")
}

#The new way

enum LegacyReviewSDK {
    static let shared = LegacyReviewSDK()
    func submit(orderID: String, completion: @escaping (Bool, Error?) -> Void) {
        completion(true, nil)
    }
}

func requestReview(for orderID: String) async throws -> Bool {
    try await withCheckedThrowingContinuation { continuation in
        LegacyReviewSDK.shared.submit(orderID: orderID) { approved, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: approved)
            }
        }
    }
}

func processOrder(_ orderID: String) async throws {
    let approved = try await requestReview(for: orderID)
    print("Approved: \(approved)")
}

#Why it matters

  • Wraps any callback API in a single async throws call without touching or forking the original SDK
  • withCheckedThrowingContinuation adds a runtime check in debug builds that catches double-resume and never-resume bugs
  • withUnsafeContinuation/withUnsafeThrowingContinuation drop that check for a small performance win once the wrapper is verified correct
  • Keeps the bridging code localized to one function instead of leaking completion-handler style through the rest of the call chain

#Gotcha

The continuation's resume must be called exactly once on every code path, including error and early-return branches — calling it twice is a runtime crash under the checked variants, and never calling it at all leaves the awaiting task suspended forever with no timeout. Audit every branch of the wrapped callback, especially ones that can fire more than once (like a delegate method), before trusting the wrapper.

swiftconcurrency

Related shorts