Using Swift's Result Type for Cleaner Error Handling in Network Requests
I've seen too many network layers littered with nested optionals, force-unwraps, and scattered error checks. It's not just messy—it's fragile. One of the most practical improvements I've made in my Swift projects over the past few years is adopting Result as the standard return type for asynchronous operations, especially network calls. It brings clarity, enforces error handling, and makes the intent of your code unmistakable.
Let me walk you through a real-world scenario where this made a difference.
The Problem: Callback Hell and Silent Failures
Imagine you're building a feature that fetches user profiles from an API. The initial implementation might look like this:
func fetchUserProfile(userID: String, completion: @escaping (User?, Error?) -> Void) {
let url = URL(string: "https://api.example.com/users/\($userID)")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(nil, error)
return
}
guard let data = data else {
completion(nil, NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(user, nil)
} catch {
completion(nil, error)
}
}.resume()
}
At first glance, it works. But notice the completion handler: (User?, Error?) -> Void. This invites bugs. What if both user and error are nil? Or both non-nil? The type system doesn't prevent invalid states. Callers have to remember to check both values, and it's easy to miss one.
Worse, when you chain multiple network calls, the nesting grows quickly, and error handling becomes inconsistent.
Enter Result: Making Success and Failure Exclusive
Swift's Result type solves this by enforcing that a value is either a success or a failure—never both, never neither. It's a discriminated union, and it makes impossible states unrepresentable.
Here's the same function refactored to use Result:
func fetchUserProfile(userID: String, completion: @escaping (Result) -> Void) {
let url = URL(string: "https://api.example.com/users/\($userID)")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
let noDataError = NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"])
completion(.failure(noDataError))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(error))
}
}.resume()
}
Now the contract is clear: you get exactly one outcome. The caller must handle both cases, and the compiler helps ensure you don't forget.
Using Result at the Call Site: Clean and Safe
Here’s how you’d use this function in a view model or view controller:
func loadUserProfile(for userID: String) {
fetchUserProfile(userID: userID) { [weak self] result in
switch result {
case .success(let user):
self?.updateUI(with: user)
self?.showSuccessMessage("Profile loaded!")
case .failure(let error):
self?.handleError(error)
self?.showErrorAlert(message: error.localizedDescription)
}
}
}
The switch statement forces you to confront both outcomes. No more guessing whether a nil User means success or failure. No more silent failures because you forgot to check the error.
Why This Matters in Practice
Beyond safety, Result improves composability. You can easily map, flatMap, or chain results:
fetchUserProfile(userID: "123")
.map { $0.email }
.flatMap { fetchUserPreferences(forEmail: $0) }
.map { preferences in preferences.theme }
.catch { error in
// fallback to default theme
.success(.system)
}
(Note: This example uses a hypothetical Result extension for chaining—many libraries like Combine or custom helpers provide this, but even without them, the basic Result type is invaluable.)
I’ve found that teams adopting Result consistently report fewer bugs related to unhandled errors and clearer code reviews. It’s not about writing less code—it’s about writing code that’s harder to get wrong.
Start small: refactor one network service or data-fetching method to return Result. Use it for a week. You’ll likely find, as I did, that it changes how you think about asynchronous flow—making your code not just safer, but easier to reason about.