Greetings, traveler!
Swift has had Result.init(catching:) for a long time. It is a small convenience initializer that runs a throwing closure and stores the outcome as a value. If the closure returns successfully, you get .success. If it throws, you get .failure.
That worked nicely for synchronous code.
let result = Result {
try decodeUser(from: data)
}
But as soon as the operation became async, the convenience disappeared.
The old problem
Before Swift 6.4, Result.init(catching:) could not wrap an async throws operation.
So if you wanted to convert an async throwing call into a Result, you usually had to write this:
let result: Result<Data, Error>
do {
let data = try await fetchRemoteData()
result = .success(data)
} catch {
result = .failure(error)
}This is not difficult code. But it is boilerplate.
And it tends to appear in the same places: view models, compatibility layers, callback bridges, reducers, and APIs that store loading state as a value.
Some teams solved it by adding their own helper extension. Swift 6.4 makes that helper part of the standard library.
The new initializer
Swift 6.4 adds an async overload of Result.init(catching:).
Now you can write:
let result: Result<Data, Error> = await Result {
try await fetchRemoteData()
}The important detail is the await before Result.
The initializer itself is async because it has to run an async closure. After the line finishes, result already contains the completed outcome. It is not a task. It is not lazy. It does not store future work.
It simply runs the operation and captures the result.
What the code means
This code:
let result: Result<Data, Error> = await Result {
try await fetchRemoteData()
}is equivalent to:
let result: Result<Data, Error>
do {
let data = try await fetchRemoteData()
result = .success(data)
} catch {
result = .failure(error)
}So the new API does not introduce a new error handling model.
It only removes the manual do/catch when your goal is already to produce a Result.
async throws is still the better shape for many APIs. Result is useful when you need to keep success or failure as data.
A common iOS use case
One place where this fits well is loading state.
enum LoadingState<Value> {
case idle
case loading
case loaded(Result<Value, Error>)
}
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var state: LoadingState<Profile> = .idle
private let service: ProfileService
init(service: ProfileService) {
self.service = service
}
func loadProfile() async {
state = .loading
let result: Result<Profile, Error> = await Result {
try await service.fetchProfile()
}
state = .loaded(result)
}
}Here Result is not used instead of async throws. The service can still expose a clean async throwing API.
func fetchProfile() async throws -> ProfileThe view model converts that operation into a value because the UI state wants to store both outcomes.
Bridging to callback-based APIs
Another useful case is bridging modern async code to an older result-based callback.
func loadAvatar(completion: @escaping (Result<Avatar, Error>) -> Void) {
Task {
let result: Result<Avatar, Error> = await Result {
try await avatarService.loadAvatar()
}
completion(result)
}
}This kind of code appears during migrations.
Maybe most of the app already uses async/await, but one module still expects a completion handler. The new initializer keeps the bridge small and readable.
Typed throws support
The proposed API also fits typed throws. For example, an async function can throw a specific error type:
enum ProfileError: Error {
case unauthorized
case notFound
}
func fetchProfile() async throws(ProfileError) -> Profile {
// Fetch profile
}Then Result can preserve that failure type:
let result: Result<Profile, ProfileError> = await Result {
try await fetchProfile()
}If your API already models errors precisely, the conversion to Result does not have to erase them to Error.
Be careful with cancellation
Result.init(catching:) catches thrown errors and stores them as .failure.
So if the async operation throws CancellationError, that cancellation can become a failure value.
let result: Result<Data, Error> = await Result {
try await fetchRemoteData()
}Sometimes that is exactly what you want. For example, your UI state may need to say that the operation ended without producing data.
But in other flows, cancellation should stop the current task rather than become a value that continues through the system.
In those cases, plain try await is still clearer.
When not to use it
The new initializer is useful, but it is not something to put around every async call.
This is probably unnecessary:
let result: Result<Profile, Error> = await Result {
try await service.fetchProfile()
}
let profile = try result.get()If you immediately unwrap the Result, the extra conversion does not buy much.
This is simpler:
let profile = try await service.fetchProfile()Use Result when the result itself is the thing you need to pass, store, compare, or expose.
Final thought
Most of the time, async throws should stay as async throws. But when you need to turn an async operation into a value, Swift 6.4 finally gives Result the initializer it was missing.
