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

  1. Explicit failure domain – Callers see exactly which errors are possible without digging into documentation.
  2. No forced try propagation – UI code stays simple; you only switch on the Result.
  3. Concurrency stays naturalasync let launches the three requests in parallel, then each await resolves a Result.
  4. Testability – Mocking APIClient to return predetermined Result values 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 can try 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.