When I first started networking code in Swift, I kept rewriting the same boilerplate: create a URLRequest, fire off a data task, check for errors, try to decode JSON, and then propagate either a model or an error back to the caller. Over time this duplication became a source of bugs and made testing painful. I eventually extracted a small, generic helper that wraps URLSession’s dataTaskPublisher (or the newer async/await API) into a single function that returns a Result. The pattern has saved me hours in every project since, and it works equally well for simple GET calls and more complex authenticated requests.

The Problem: Repetitive Error Handling and Decoding

Consider a typical fetch‑user function:

func fetchUser(id: Int, completion: @escaping (User?, Error?) -> Void) {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(nil, error)
            return
        }
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            completion(nil, URLError(.badServerResponse))
            return
        }
        guard let data = data else {
            completion(nil, URLError(.badURL))
            return
        }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(user, nil)
        } catch {
            completion(nil, error)
        }
    }
    task.resume()
}

That’s a lot of lines just to get a single model. Imagine doing this for every endpoint, adding authentication headers, query parameters, or custom retry logic. The code quickly becomes noisy and hard to maintain.

The Solution: A Generic, Result‑Based Wrapper

I created a small utility that takes care of the request lifecycle, leaves the caller only to specify the URL, HTTP method, and the expected response type. It returns a Result where the success case holds the decoded model and the failure case holds an Error (either from networking or decoding). Because it uses Swift’s concurrency model, the caller can await the result or combine it with other async work.

import Foundation

/// A simple error type that groups networking and decoding issues.
enum NetworkError: Error {
    case invalidURL
    case requestFailed(Error)
    case invalidResponse
    case decodingError(Error)
    case unknown
}

/// Performs a URLRequest and decodes the response into the supplied Decodable type.
/// - Parameters:
///   - request: The URLRequest to execute.
///   - decoder: A JSONDecoder instance (defaults to .init).
/// - Returns: A Result containing the decoded value or a NetworkError.
func fetch(_ request: URLRequest,
                         decoder: JSONDecoder = JSONDecoder()) async -> Result {
    guard let url = request.url else {
        return .failure(.invalidURL)
    }

    do {
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            return .failure(.invalidResponse)
        }

        do {
            let decoded = try decoder.decode(T.self, from: data)
            return .success(decoded)
        } catch {
            return .failure(.decodingError(error))
        }
    } catch {
        return .failure(.requestFailed(error))
    }
}

Notice how the function is generic over T where T : Decodable. The caller supplies the concrete type they expect, and the function handles the rest. The use of async lets us write sequential‑looking code without nesting completion handlers.

Real‑World Scenario: Loading a User Profile

Let’s say we have a screen that shows a logged‑in user’s profile. The UI needs the user’s name, avatar URL, and a list of recent posts. Instead of scattering networking code across the view model, we centralize it.

struct UserProfile: Decodable {
    let id: Int
    let name: String
    let avatarURL: URL
    let recentPosts: [Post]
}

struct Post: Decodable {
    let id: Int
    let title: String
}

class ProfileViewModel: ObservableObject {
    @Published var userProfile: UserProfile?
    @Published var errorMessage: String?
    
    func loadProfile(for userId: Int) {
        Task {
            var request = URLRequest(url: URL(string: "https://api.example.com/users/\(userId)")!)
            request.httpMethod = "GET"
            request.addValue("application/json", forHTTPHeaderField: "Accept")
            
            let result: Result = await fetch(request)
            
            switch result {
            case .success(let profile):
                await MainActor.run {
                    self.userProfile = profile
                    self.errorMessage = nil
                }
            case .failure(let networkError):
                await MainActor.run {
                    self.errorMessage = self.description(for: networkError)
                }
            }
        }
    }
    
    private func description(for error: NetworkError) -> String {
        switch error {
        case .invalidURL: return "The request URL is invalid."
        case .requestFailed(let underlying): return "Network request failed: \(underlying.localizedDescription)"
        case .invalidResponse: return "Server returned an unexpected status code."
        case .decodingError(let underlying): return "Failed to decode response: \(underlying.localizedDescription)"
        case .unknown: return "An unknown error occurred."
        }
    }
}

By delegating the networking details to fetch(_:), the view model stays focused on state management and UI updates. If we later need to add authentication tokens, we can modify the request construction in one place without touching the core networking logic.

Why This Approach Works Well

  • Single source of truth: All error handling, status‑code validation, and decoding live in one function, reducing the chance of inconsistencies.
  • Type safety: The generic return type ensures the caller gets exactly the model they asked for; a mismatch triggers a compile‑time error.
  • Compatibility with modern Swift: Because the helper is async, it composes naturally with other asynchronous APIs (e.g., try await, TaskGroup, or AsyncSequence).
  • Testability: In unit tests you can inject a mock URLProtocol or replace URLSession.shared with a stubbed session that returns predefined data, making it easy to verify both success and failure paths.
  • Extensibility: Adding support for custom headers, timeout intervals, or retry logic only requires adjusting the request before it’s passed to fetch.

Potential Enhancements

  1. Introduce a retry mechanism using an exponential backoff strategy for transient failures.
  2. Allow the caller to provide a custom Decoder (e.g., PropertyListDecoder) via a protocol.
  3. Wrap the result in a dedicated APIResponse type that also returns raw Data or response headers when needed.
  4. Integrate with Combine by offering a Future overload for projects that haven’t fully migrated to async/await.

Even though the snippet is small, adopting it early in a project pays dividends: less duplicated code, clearer intent, and a more reliable networking layer.

Give it a try in your next SwiftUI or UIKit app. Replace those repetitive dataTask closures with a single awaitable call, and you’ll find yourself spending more time on feature work and less on debugging networking boilerplate.