Using Swift's Result Type for Explicit Error Handling in Async Code
Learn how Swift's Result type makes error handling explicit and composable, especially in async/await code, with a concrete example and trade‑offs.
07 Jul 2026, 16:12 UTC

The problem: hidden errors in throwing async functions
When you mark an asynchronous function with throws, the compiler hides the error path inside the function’s implementation. Callers must write do‑try‑catch blocks or propagate the error with throws again, which can make it easy to overlook a failure case, especially in long chains of await calls.
Thesis: Result makes error handling visible and composable
By returning a Result value instead of throwing, you expose both the success and error cases in the type signature. This lets you use familiar functional tools — map, flatMap, and switch — to handle errors without losing the ability to await other asynchronous work.
What Result gives you
Result.success(value)wraps a valid value.Result.failure(error)wraps an error conforming toError.- The standard library provides
get()(throws if error),map(_:)(transforms success), andflatMap(_:)(chains operations that may also fail).
Result with synchronous code
Consider a function that parses a JSON string into an integer:
func parseInt(from json: String) -> Result {
guard let data = json.data(using: .utf8) else {
return .failure(NSError(domain: "Parse", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid UTF8"]))
}
do {
if let number = try JSONSerialization.jsonObject(with: data) as? Int {
return .success(number)
}
return .failure(NSError(domain: "Parse", code: 2, userInfo: [NSLocalizedDescriptionKey: "Not an integer"]))
} catch {
return .failure(error)
}
}
The caller can now decide how to handle the outcome:
let result = parseInt(from: "\"42\"")
switch result {
case .success(let value):
print("Parsed \(value)")
case .failure(let err):
print("Parse failed: \(err.localizedDescription)")
}
Result in an asynchronous context
Async functions can return Result just like synchronous ones. This lets you await other async work while still propagating errors explicitly.
func fetchUser(id: String) async -> Result {
let url = URL(string: "https://api.example.com/users/\(id)")!
do {
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
return .success(user)
} catch {
return .failure(error)
}
}
You can chain multiple async calls using flatMap:
func fetchUserProfile(id: String) async -> Result {
await fetchUser(id: id).flatMap { user in
// Assume fetchPreferences is also async and returns Result
await fetchPreferences(userId: user.id).map { prefs in
Profile(user: user, preferences: prefs)
}
}
}
Worked example: building a dashboard view model
Suppose we need to load a user’s settings and recent activity, both of which can fail. Using Result we can combine them without nested do‑try‑catch blocks:
struct DashboardState {
let settings: Settings
let activity: [Activity]
}
func loadDashboard(userId: String) async -> Result {
async let settingsResult = fetchSettings(userId: userId)
async let activityResult = fetchRecentActivity(userId: userId)
// Wait for both, then combine
return await withUnsafeThrowingContinuation { continuation in
Task {
let settings = await settingsResult
let activity = await activityResult
switch (settings, activity) {
case (.success(let s), .success(let a)):
continuation.resume(returning: .success(DashboardState(settings: s, activity: a)))
case (.failure(let e), _):
continuation.resume(returning: .failure(e))
case (_, .failure(let e)):
continuation.resume(returning: .failure(e))
}
}
}
}
The caller receives a single Result that tells them whether the whole dashboard loaded successfully or which part failed.
Trade‑off: boilerplate vs. clarity
Using Result forces you to handle each case explicitly. In simple scenarios this can feel verbose compared to a single try that propagates errors automatically. However, the verbosity makes error paths visible in the type system, reducing the chance of silently ignoring failures — especially valuable in large codebases or when building APIs for other teams.
Actionable guidance
- Start by replacing throwing functions that are part of a public API with
Resultreturn types when you want callers to see error possibilities at a glance. - Keep using
throwsfor internal helper functions where the overhead of explicit handling outweighs the benefit. - When mixing the two, convert with
Result { try expression() }ortry? result.get()and be aware thattry?discards the error. - Measure the impact: compile times are unchanged; runtime overhead is negligible because
Resultis a simple enum.
By making errors part of the return type, you gain a clear, composable way to handle failures in both synchronous and asynchronous Swift code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.