A Strategy for Moving to Swift 6 and async/await

In the iOS 18 / Xcode 16 development cycle (2024), I've converted a lot of my code from using completion handlers to using async/await.

This article outlines the general strategy I've used, which has been quite effective.


Assume we're starting with a widely-used function like this:

func myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    completion(.success(MyStruct()))
}

We want to achieve two things:

  1. Make it async/await
  2. Update any references to it to use that new async version.

To get started, you can use Xcode refactor tools to create an async wrapper:

Use the "Add Async Wrapper" option.

This leaves you with code that looks roughly like this:

func myFunc() async throws -> MyStruct {
    try await withCheckedThrowingContinuation { continuation in
        myFunc { result in
            continuation.resume(with: result)
        }
    }
}

@available(*, renamed: "myFunc()")
func myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    completion(.success(MyStruct()))
}

Next, mark the original function as deprecated:

@available(*, deprecated, renamed: "myFunc()")
func myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    completion(.success(MyStruct()))
}

This will quickly let you identify which code you need to change to use the new async method. The problem is that the new async method is also now calling a deprecated method.

To solve this, rename the original method so it holds the logic. The deprecated method will now call that also:

func myFunc() async throws -> MyStruct {
    try await withCheckedThrowingContinuation { continuation in
        _myFunc { result in
            continuation.resume(with: result)
        }
    }
}

@available(*, deprecated, renamed: "myFunc()")
func myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    _myFunc(completion: completion)
}

private func _myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    completion(.success(MyStruct()))
}

The final two steps are now:

  1. Remove all calls to the deprecated method
  2. Rewrite the async method so it uses async/await instead of continuations.

You can do these in either order at the speed that suits your own development. The only thing I'd caution about rewriting the async method while leaving calls to the deprecated method is that you essentially have to maintain two versions of the same function.

Ideally you'd remove all references to the deprecated method first, and then you can rewrite the logic of myFunc in your own time:

func myFunc() async throws -> MyStruct {
    let x = await myOtherFunc()
    return x
}

One concrete example of where I've used this strategy is converting all of my HealthKit code. Previously it would use something like HKSampleQuery, which uses completion handlers. Now the async/await way would be to use HKSampleQueryDescriptor.

In the example above, I would keep using, say, HKSampleQuery in the _myFunc() method, and to complete the conversion to async/await I'd then use HKSampleQueryDescriptor in the async method:

func myFunc() async throws -> MyStruct {
    let descriptor = HKSampleQueryDescriptor(...)

    // ...

    let samples = try await descriptor.result(for: HKHealthStore())

    // ...
}

@available(*, deprecated, renamed: "myFunc()")
func myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    _myFunc(completion: completion)
}

func _myFunc(completion: @escaping (Result<MyStruct, Error>) -> Void) {
    let query = HKSampleQuery(...) {
       // ...
       completion(...)
    }
}

Obviously this isn't a functioning code block, but hopefully demonstrates the broad technique used.