Simplifying Async Error Handling in Swift with Result and async/await
Why the Result type still matters
When Swift introduced async/await it felt like the final answer to callback hell. Yet many codebases still pass around Result<T, Error> because it makes failure explicit at the call site without forcing a try everywhere. I’ve found that mixing the two gives the best of both worlds: readable linear code for the happy path and a clear, typed error channel for the unhappy one.
A real‑world scenario: a resilient network layer
Imagine a service that fetches a user’s profile, then their recent orders, and finally a recommendation list. Each step can fail for different reasons — network timeout, decoding error, or a backend‑specific error code. Throwing a generic Error forces every caller to do/catch and inspect the error type. Returning a Result lets the caller decide: retry, show a friendly message, or propagate a custom enum.
The building blocks
- Result<Success, Failure> – a value that is either a success with a payload or a failure with a typed error.
- async/await – suspends the function without blocking a thread.
- Custom error enum – gives you exhaustive switching later.
Putting it together
Below is a production‑ready snippet from a recent project. It shows a small network client that returns Result from async functions, a unified error type, and a caller that handles each case cleanly.
import Foundation
// MARK: - Error model
/// All failures that can surface from the API layer.
enum APIError: Error, Equatable {
case transport(Error) // network / URLSession errors
case decoding(Error) // JSONDecoder failures
case server(Int, String) // status code + backend message
case unknown
static func == (lhs: APIError, rhs: APIError) -> Bool {
switch (lhs, rhs) {
case (.transport, .transport),
(.decoding, .decoding),
(.unknown, .unknown):
return true
case let (.server(lCode, lMsg), .server(rCode, rMsg)):
return lCode == rCode && lMsg == rMsg
default:
return false
}
}
}
// MARK: - Tiny request helper
struct APIClient {
private let session: URLSession
private let decoder = JSONDecoder()
init(session: URLSession = .shared) {
self.session = session
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
/// Generic GET that returns a Result, keeping the call site free of try/catch.
func get(_ url: URL) async -> Result {
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else {
return .failure(.unknown)
}
guard (200..<300).contains(http.statusCode) else {
// Try to decode a backend error payload, fall back to status code.
let msg = (try? decoder.decode(BackendError.self, from: data))?.message
?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
return .failure(.server(http.statusCode, msg))
}
let value = try decoder.decode(T.self, from: data)
return .success(value)
} catch let urlError as URLError {
return .failure(.transport(urlError))
} catch {
return .failure(.decoding(error))
}
}
}
// MARK: - Example models
struct UserProfile: Decodable { let id: Int; let name: String }
struct Order: Decodable { let id: String; let total: Double }
struct Recommendation: Decodable { let productId: String; let score: Double }
struct BackendError: Decodable { let message: String }
// MARK: - High‑level service using the client
final class ProfileService {
private let client = APIClient()
private let base = URL(string: "https://api.example.com\))!
func fetchProfile(userId: Int) async -> Result {
let url = base.appendingPathComponent("users/\(userId)\/profile\)
return await client.get(url)
}
func fetchOrders(userId: Int) async -> Result<[Order], APIError> {
let url = base.appendingPathComponent("users/\(userId)\/orders\)
return await client.get(url)
}
func fetchRecommendations(userId: Int) async -> Result<[Recommendation], APIError> {
let url = base.appendingPathComponent("users/\(userId)\/recommendations\)
return await client.get(url)
}
}
// MARK: - Caller: a view model that decides what to show
@MainActor
final class DashboardViewModel: ObservableObject {
@Published var profile: UserProfile?
@Published var orders: [Order] = []
@Published var recommendations: [Recommendation] = []
@Published var errorMessage: String?
private let service = ProfileService()
func loadDashboard(for userId: Int) async {
// Run the three requests concurrently – they’re independent.
async let profileResult = service.fetchProfile(userId: userId)
async let ordersResult = service.fetchOrders(userId: userId)
async let recsResult = service.fetchRecommendations(userId: userId)
// Await each Result and handle it individually.
switch await profileResult {
case .success(let p):
self.profile = p
case .failure(let e):
self.errorMessage = friendlyMessage(for: e)
return // abort early – no profile, no dashboard
}
switch await ordersResult {
case .success(let o): self.orders = o
case .failure(let e): self.errorMessage = friendlyMessage(for: e)
}
switch await recsResult {
case .success(let r): self.recommendations = r
case .failure(let e): self.errorMessage = friendlyMessage(for: e)
}
}
private func friendlyMessage(for error: APIError) -> String {
switch error {
case .transport(let u):
return u.code == .timedOut ? "Request timed out. Please try again." : "Network error: \(u.localizedDescription)\"\n case .decoding: return "We couldn’t read the server response."\n case .server(let code, let msg):
return code == 404 ? "Profile not found." : "Server error (\(code)): \(msg)\"\n case .unknown: return "An unexpected error occurred."\n }
}
}
What makes this pattern shine
- Explicit failure domain – Callers see exactly which errors are possible without digging into documentation.
- No forced
trypropagation – UI code stays simple; you onlyswitchon theResult. - Concurrency stays natural –
async letlaunches the three requests in parallel, then eachawaitresolves aResult. - Testability – Mocking
APIClientto return predeterminedResultvalues is trivial; no need to throw in tests.
Tip: If you eventually need to bridge to a throwing API (e.g., a third‑party library), add a tiny helper:
extension Result { func get() throws -> Success { switch self { case .success(let v): return v; case .failure(let e): throw e } } }. Then you cantry await profileResult.get()when you truly want a throw.
When to reach for pure throws instead
If the error hierarchy is flat — say, only NetworkError and DecodingError — and every caller must abort on any failure, throws reduces boilerplate. But the moment you need different recovery strategies per error type (retry on timeout, show a custom screen on 404, log and continue on decoding glitches), the Result approach pays off.
Final thoughts
Swift’s Result isn’t a relic; it’s a composable primitive that works beautifully with async/await. By returning typed results from your async boundaries you keep the happy path linear, give callers a clear contract, and make unit testing a breeze. Next time you write a network layer, try the pattern above — you’ll thank yourself when the product manager asks for a custom “offline” screen for just the recommendations endpoint.