Why I Reach for Result with async/await

When I first started using Swift’s concurrency model, I kept seeing network layers that either threw errors or returned optionals. Both approaches work, but they force the caller to juggle try/catch or nil checks scattered throughout the code. Over time I found that wrapping the asynchronous call in a Result type gives me a single, expressive value that clearly separates the happy path from failure cases.

In practice, this pattern reduces boilerplate, makes unit tests easier to read, and keeps the UI layer focused on presentation rather than error‑propagation logic. Below is a real‑world example from a recent project where we needed to fetch a user’s profile, display it, and show a friendly message if something went wrong.

Scenario: Fetching a User Profile

Our app talks to a REST endpoint that returns JSON like:

{
  "id": "123",
  "name": "Ada Lovelace",
  "email": "ada@example.com"
}

If the request fails due to network loss or a server error, we want to show an alert with a short description. The networking layer should not know anything about UIKit; it just hands back either a User model or an error that the view model can interpret.

Defining the Model and Error Type

import Foundation

struct User: Codable, Equatable {
    let id: String
    let name: String
    let email: String
}

enum NetworkError: LocalizedError {
    case invalidURL
    case requestFailed(Error)
    case invalidResponse
    case decodingFailed(Error)
    
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The server address is malformed."\n        case .requestFailed(let underlying):
            return "Network request failed: \(underlying.localizedDescription)"\n        case .invalidResponse:
            return "Received an unexpected response from the server."\n        case .decodingFailed(let underlying):
            return "Could not parse the server response: \(underlying.localizedDescription)"\n        }
    }
}

Having a dedicated NetworkError conforming to LocalizedError lets us propagate meaningful messages straight to the UI without extra translation steps.

The Network Service

Here’s the core of the service. Notice how the function returns Result<User, NetworkError> and is marked async. The caller can await the result and then switch on the Result case.

import Foundation

final class UserService {
    private let baseURL = URL(string: "https://api.example.com\))!
    
    /// Fetches the profile for the given user identifier.
    /// - Returns: A Result containing either the User model or a NetworkError.
    func fetchUser(id: String) async -> Result<User, NetworkError> {
        // Build the request URL; return early if it’s malformed.
        let endpoint = baseURL.appendingPathComponent("users/\(id)" )
        var request = URLRequest(url: endpoint)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Accept\)) 
        
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            
            // Validate HTTP status code.
            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                return .failure(.invalidResponse)
            }
            
            // Decode the JSON into our User model.
            do {
                let user = try JSONDecoder().decode(User.self, from: data)
                return .success(user)
            } catch {
                return .failure(.decodingFailed(error))
            }
        } catch {
            // Any error thrown by URLSession (e.g., network failure) lands here.
            return .failure(.requestFailed(error))
        }
    }
}

The function is deliberately linear: each step either succeeds and moves forward or returns a failure case immediately. This makes the success path obvious at a glance.

Using the Service in a View Model

In MVVM, the view model calls the service, then maps the Result to UI‑friendly state.

import Combine
import SwiftUI

final class ProfileViewModel: ObservableObject {
    @Published private(set) var user: User?
    @Published private(set) var errorMessage: String?
    
    private let service = UserService()
    private var cancellables = Set<AnyCancellable>()
    
    func loadProfile(for userID: String) {
        Task {
            let result = await service.fetchUser(id: userID)
            // Switch on Result to update UI state.
            switch result {
            case .success(let user):
                self.user = user
                self.errorMessage = nil
            case .failure(let networkError):
                self.user = nil
                self.errorMessage = networkError.errorDescription
            }
        }
    }
}

Because the service already handled all networking concerns, the view model only needs to decide what to show. The switch statement makes it impossible to forget a case, and the compiler will warn if we add a new error type later.

Why This Beats Throwing or Optionals

  • Explicit error handling: The caller sees both success and failure as first‑class values, not as a hidden exception that might be missed.
  • Testability: Unit tests can assert on Result.success or Result.failure without needing to expect thrown errors.
  • Separation of concerns: The networking layer stays pure Swift; it doesn’t import UIKit or SwiftUI, making it reusable across platforms (iOS, macOS, watchOS).
  • Composability: If you later need to retry, you can wrap the async call in a retry loop that still returns a Result.

A Small Callout on Cancellation

Swift’s cooperative cancellation works naturally with async/await. If the calling task is cancelled, the URLSession.data(for:) call will throw a CancellationError, which we treat like any other network failure. Should you need to differentiate cancellation from other errors, add a specific case to NetworkError and check for it in the catch block.

Wrapping Up

Adopting Result with async/await gave our team a cleaner, more predictable way to handle network responses. It cut down on nested do‑try‑catch blocks, made error messages consistent, and simplified the UI layer’s responsibilities. If you’re building any kind of asynchronous service in Swift, I recommend giving this pattern a try—it’s one of those small changes that pays off every time you open the code.