Introduction

When I started using Swift’s Result type a few years ago, my network layer suddenly became easier to reason about. Instead of juggling optional URLSession completions and do‑catch blocks that could leak errors, I could treat success and failure as first‑class values. This article walks through a practical pattern I rely on daily: building a clean, testable API client with Result.

Why Result Types Matter

The core idea behind Result is simple—model both success and failure as distinct values. In Swift, Result<T, E> is an enum with two cases: .success(T) and .failure(E). By forcing the caller to handle both cases, we eliminate the dreaded if case .success(let data) else { return } patterns that often hide bugs.

Working with Resultd> also makes unit testing trivial. You can feed mock Result values directly into your business logic, bypassing the need for complex URLSession mocking. Moreover, Result integrates nicely with Combine, allowing you to chain operations without juggling callbacks.

Real‑World Example: Fetching Weather Data

Imagine we need to fetch current weather for a given city. The API returns JSON and may fail due to network issues, invalid coordinates, or server errors. A traditional approach would look like this:

func fetchWeatherTraditional(city: String, completion: @escaping (Data?, Error?) -> Void) {
    var request = URLRequest(url: weatherURL(for: city))
    request.httpMethod = "GET"
    URLSession.shared.dataTask(with: request) { data, response, error in
        completion(data, error)
    }.resume()
}

// Usage
fetchWeatherTraditional(city: "London") { data, error in
    guard let data = data else {
        print("Error: \(error?.localizedDescription ?? "unknown")")
        return
    }
    // decode...
}

This pattern leaves the caller to decide what to do with nil data and an optional error. The Result approach encapsulates that decision:

enum WeatherError: Error {
    case networkFailure(Error)
    case invalidResponse
    case decodingFailure(DecodingError)
}

struct WeatherResponse: Decodable {
    let temperature: Double
    let description: String
}

func fetchWeather(city: String) async -> Result {
    guard let url = URL(string: "https://api.weather.com/v1/current?q=\(city)") else {
        return .failure(.invalidResponse)
    }

    do {
        let (data, response) = try await URLSession.shared.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse,
              200...299.contains(httpResponse.statusCode) else {
            return .failure(.invalidResponse)
        }
        let decoder = JSONDecoder()
        let model = try decoder.decode(WeatherResponse.self, from: data)
        return .success(model)
    } catch let error as DecodingError {
        return .failure(.decodingFailure(error))
    } catch {
        return .failure(.networkFailure(error))
    }
}

Now the caller receives a single value that is either a successful model or a well‑typed error. The usage becomes straightforward:

Task {
    switch await fetchWeather(city: "London") {
    case .success(let weather):
        print("Temperature: \(weather.temperature)°C")
    case .failure(let error):
        print("Failed: \(error.localizedDescription)")
    }
}

Because fetchWeather returns an Result, we can also easily compose it. For example, we could chain a second API call only when the first succeeds:

func fetchWeatherWithForecast(city: String) async -> Result {
    async let current = fetchWeather(city: city)
    async let forecast = fetchForecast(city: city)

    switch (await current, await forecast) {
    case (.success(let currentWeather), .success(let forecastData)):
        return .success(WeatherWithForecast(current: currentWeather, forecast: forecastData))
    case (.failure(let error), _), (_, .failure(let error)):
        return .failure(error)
    }
}

Notice how the async let syntax lets us run parallel network requests without worrying about nested closures. The Result type keeps the error handling explicit.

Putting It All Together – a Reusable API Layer

To keep our codebase DRY, we can abstract the above pattern into a generic service. The following extension on URLSession provides a data(for:) method that returns a Result directly:

extension URLSession {
    func data(for request: URLRequest) async -> Result where ResultType: Decodable {
        do {
            let (data, response) = try await self.data(for: request)
            guard let http = response as? HTTPURLResponse,
                  200...299.contains(http.statusCode) else {
                return .failure(.invalidResponse)
            }
            let decoder = JSONDecoder()
            let model = try decoder.decode(ResultType.self, from: data)
            return .success(model)
        } catch let error as DecodingError {
            return .failure(.decodingFailure(error))
        } catch {
            return .failure(.networkFailure(error))
        }
    }
}

The APIError enum is shared across all services:

enum APIError: Error {
    case invalidResponse
    case networkFailure(Error)
    case decodingFailure(DecodingError)
}

Now our weather service becomes a thin wrapper:

struct WeatherService {
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func currentWeather(for city: String) async -> Result {
        guard let url = URL(string: "https://api.weather.com/v1/current?q=\(city)") else {
            return .failure(.invalidResponse)
        }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        return await session.data(for: request)
    }
}

Each caller can now focus on business logic rather than error‑prone callback nesting. The pattern also makes it trivial to add features like request caching or logging by wrapping the session.

Common Pitfalls and Tips

  • Mixing Result with optional chaining. Remember that Result already encodes failure, so avoid falling back to guard let inside a .success branch.
  • Over‑enumerating errors. Keep the APIError cases broad where possible. If you need fine‑grained handling, consider attaching an Error to the failure case rather than proliferating enum entries.
  • Async‑await compatibility. Use async functions that return Result to stay consistent with the rest of your codebase. Mixing Result with completion‑handler callbacks can confuse the flow.

By adhering to these guidelines, the Result pattern stays clean and maintainable.

Conclusion

Swift’s Result type is more than a fancy wrapper; it is a design tool that forces you to think about success and failure upfront. In my daily work, swapping raw callbacks for Result‑based async functions has reduced the number of runtime crashes and made our test suite far more predictable. If you haven’t embraced Result yet, now is the perfect time to integrate it into your network layer, API client, or any place where errors are a natural part of the flow. The payoff is cleaner code, better test coverage, and fewer surprises when things go wrong.