Skip to Content
Course content

195: Building a Type-Safe API Client Layer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a teammate who had spent nearly an entire afternoon debugging a 404 error. The API was fine, the server was up, and the authentication was working. It turned out he had a tiny typo in a hardcoded string: "/api/v1/userrs/profile" instead of "/api/v1/users/profile". Now, we've all been there. The problem isn't the typo itself; it's that we're letting the compiler treat our API structure as a collection of "magic strings." When you rely on strings to define your network layer, you're essentially telling Swift, "Trust me, I know how to spell," which is a dangerous bet to make in a codebase with thousands of lines.

Defining the Endpoint Blueprint

To stop these runtime surprises, we need to move the source of truth from strings to types. I prefer using a protocol to define what an "Endpoint" actually is. Instead of passing a URL directly to a session, we create a contract that forces every request to define its own requirements—like the path, the HTTP method, and any necessary query items.

protocol APIEndpoint {
    var path: String { get }
    var method: HTTPMethod { get }
    var queryItems: [URLQueryItem]? { get }
}

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

By doing this, we've created a blueprint. Now, any part of the app that needs to make a network call doesn't need to know how to build a URL; it just needs to provide an object that conforms to APIEndpoint.

Enforcing Request Integrity with Enums

The real magic happens when you combine that protocol with an enum. Enums are perfect for API clients because they allow us to use associated values for dynamic paths. If you're fetching a specific product by ID, you shouldn't be concatenating strings in your View Model. Instead, you bake that ID right into the enum case.

enum ProductEndpoint: APIEndpoint {
    case getAllProducts
    case getProductDetails(id: Int)
    case updateStock(id: Int, quantity: Int)

    var path: String {
        switch self {
        case .getAllProducts: 
            return "/products"
        case .getProductDetails(let id): 
            return "/products/\(id)"
        case .updateStock(let id, _): 
            return "/products/\(id)/stock"
        }
    }

    var method: HTTPMethod {
        switch self {
        case .getAllProducts, .getProductDetails: return .get
        case .updateStock: return .put
        }
    }

    var queryItems: [URLQueryItem]? {
        return nil // Add specific filters here if needed
    }
}

I love this approach because if I ever need to change the path for "Product Details," I change it in exactly one place. The rest of the app just calls .getProductDetails(id: 123) and doesn't care about the underlying string.

Connecting the Pieces with a Generic Client

Now we need a client that can take any APIEndpoint and return a decoded model. The trick here is using generics. We don't want a getProductClient and a getUserClient; we want one client that understands how to handle any type that conforms to Decodable.

class APIClient {
    private let baseURL = URL(string: "https://api.mystore.com/v1")!
    private let session = URLSession.shared

    func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
        var components = URLComponents(url: baseURL.appendingPathComponent(endpoint.path), resolvingAgainstBaseURL: false)
        components?.queryItems = endpoint.queryItems
        
        guard let url = components?.url else {
            throw URLError(.badURL)
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue

        let (data, response) = try await session.data(for: request)
        
        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }

        return try JSONDecoder().decode(T.self, from: data)
    }
}

Notice how the request method is completely agnostic about what it's fetching. Whether it's a Product, a User, or an Order, the logic remains the same. You've successfully pushed all the "danger" (the strings and the URL construction) into a controlled, type-safe layer. Now, if you try to pass a string where an ID should be, the compiler will yell at you before you ever hit "Run."




📋 Practical Task

Exercise: Refactoring the BookStore API for Type Safety

You have been handed a legacy BookClient class that uses hardcoded strings for its requests, making it prone to typos and difficult to maintain. Your task is to refactor this into a type-safe system.

Requirements:

  • Create an APIEndpoint protocol with path, method, and queryItems.
  • Implement a BookEndpoint enum that conforms to APIEndpoint. It must handle three cases:
    • listBooks (GET /books)
    • bookDetails(isbn: String) (GET /books/{isbn})
    • addBook(title: String, author: String) (POST /books)
  • Create a generic APIClient class with a request<T: Decodable>(_ endpoint: APIEndpoint) method that constructs the URLRequest and decodes the response.
  • Write a small snippet of code demonstrating how to call bookDetails(isbn: "978-3-16-148410-0") using your new client.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.