I've been wondering the same thing lately, and I think the answer depends on the complexity of the problem.
In the example you provided, there might be little (or no) benefit using async/await vs completion handlers. However, consider the following example:
func fetchAllData(firstId: String, completion: @escaping (Result<ImportantData, Error>) -> Void) {
fetchInitialData(firstId) { [weak self] result in
switch result {
case .success(let initialData):
self?.fetchSecondaryData(initialData.id) { result in
switch result {
case .success(let secondaryData):
self?.fetchFinalData(secondaryData.id) { result in
switch result {
case .success(let imporantData): completion(.success(importantData))
case .failure(let finalError): completion(.failure(finalError)
}
case .failure(let secondError): completion(.failure(secondError)
}
case .failure(let firstError): completion(.failure(firstError)
}
}
}
A silly example, to be sure, but I'm sure there are times when developers need to use multiple asynchronous methods in a synchonrous fashion (if that makes sense). each subsequent call requires info from the previous. And the above example is assuming no other work aside from another async call is being made on each success. Before async/await, that nightmare above was probably a 'good' solution.
The same code using async/await:
func fetchAllData(firstId: String) async throws -> ImportantData {
let initialData = try await fetchInitialData(firstId)
let secondaryData = try await fetchSecondaryData(initialData.id)
return try await fetchFinalData(secondaryData.id)
}
It's almost comical how simple async/await can make these complex nested completion handlers. And even if you wanted to keep the outer-most completion handler, you can still clean things up with async/await but using a Task inside of the outermost method alongside a do/catch:
func fetchAllData(firstId: String, completion: @escaping (Result<ImportantData, Error>) -> Void) {
Task {
do {
let initialData = try await fetchInitialData(firstId)
let secondaryData = try await fetchSecondaryData(initialData.id)
let importantData = try await fetchFinalData(secondaryData.id)
completion(.success(importantData))
} catch {
completion(.failure(error))
}
}
}
In the last example, you'll need to decide when to switch back to the MainActor (MainThread) if any UI work is being done with the ImportantData. Ideally Threading concerns should be isolated to a specific file, but I don't think it would be too ridiculous to use await MainActor.run { completion(/*result here*/) } on the final completions if necessary.