How does an Objective-C completion-handler method become an async function in Swift, and how do you control it?
Predict first
What does this print?
// Objective-C: - (void)fetchValue:(NSString *)key completion:(void (^)(NSString *_Nullable value, NSError *_Nullable error))completion;
// calls back with the uppercased key, or with an NSError (domain "demo", code 404) when the key is "missing"
let d = Downloader()
print(try await d.fetchValue("abc"))
do { _ = try await d.fetchValue("missing") }
catch { print((error as NSError).domain, (error as NSError).code) }
- ABC / demo 404
- abc / demo 404
- Compile error: no async overload
Mental model. The compiler writes the “wait for the delivery” plumbing for you and turns the delivery slip into a return value or a thrown problem.
The importer recognises a method whose last parameter is a block returning void and which is named like a completion handler (completion, completionHandler, handler, and a few other conventions). It adds an async overload and keeps the callback version too. The handler’s arguments become the return value. When the first parameter is an optional result and the second is an optional NSError, the pair collapses to throws -> Value: so -fetchValue:completion:(NSString *value, NSError *error) becomes func fetchValue(_ key: String) async throws -> String. You call it with try await and no continuation code of your own.
You can steer the import from the header. NS_SWIFT_ASYNC_NAME renames the async form, NS_SWIFT_ASYNC(n) says which parameter is the handler when the name is not recognisable, NS_SWIFT_DISABLE_ASYNC suppresses it, and NS_SWIFT_ASYNC_NOTHROW says the error argument must not throw. The generated wrapper is a checked continuation, so a handler that is never called leaves the task suspended forever and one that is called twice is reported as a misuse, which is a bug in the Objective-C method rather than in your Swift.
The success pair becomes the return value and the error pair becomes a thrown NSError that keeps its domain and code.
Common traps
- Assuming the Objective-C method always calls its handler once. An early return that forgets to call it hangs the awaiting task.
- Awaiting on the wrong actor. The method may call back on a background queue; the async version resumes on the caller’s executor, not the callback queue.
They might follow up with
- Does the old callback form still exist? Yes; both overloads are imported, and the compiler picks by context.
- Why are Swift error and result in two parameters collapsed? The “result or error, never both” convention is exactly what
throwsexpresses.
Related questions
- 4.4.3 How do Swift errors and Objective-C NSError translate across the border?
- 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
- 10.12.2 What happens when a continuation is resumed twice or never, and how do you wrap a callback API correctly?
← How do @optional protocol methods work, and how do you write a…