← Back to blog

Modeling API responses with enums in Swift: an elegant solution for unpredictable APIs

When an API returns HTTP 200 but with different formats depending on the outcome, an enum with associated values is the cleanest solution in Swift.

I don’t know if this is more common than it seems, but sometimes APIs handle a response standard different from what we commonly expect. They return HTTP 200, but the response has different formats depending on whether everything went well or not. For that, Swift has an option we can leverage: enum with associated values.

  1. ✅ Success (with data)
  2. ❌ Error (with code and message)
  3. 🌀 Weird response we don’t even know how to decode

In this post I’ll show you how to create a single Codable that represents the three possible scenarios an API can return.


Expected response:

{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 42,
    "name": "Ada Lovelace"
  }
}

API-specific errors:

{
  "code": 401,
  "message": "Invalid API key"
}

Unexpected messages:

{
  "unexpected_field": "who knows?"
}

🧱 The unified model with enum

We create a single enum that can represent these three scenarios:

enum APIResult<T: Decodable>: Decodable {
    case success(T)
    case failure(Int, String)
    case unknown

    private enum CodingKeys: String, CodingKey {
        case code
        case message
        case data
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        if let code = try? container.decode(Int.self, forKey: .code),
           let message = try? container.decode(String.self, forKey: .message) {

            if code == 0, let data = try? container.decode(T.self, forKey: .data) {
                self = .success(data)
            } else {
                self = .failure(code, message)
            }
        } else {
            self = .unknown
        }
    }
}

🎯 This init(from:) tries to read the code and message. If it can’t, it marks it as .unknown. If code == 0, it tries to decode the data. Otherwise, it treats it as a backend error.


🧍‍♀️ Our data model

struct User: Decodable {
    let id: Int
    let name: String
}

🔌 Example APIClient

actor APIClient {
    static let shared = APIClient()
    private let session = URLSession.shared

    func request<T: Decodable>(
        url: URL,
        expecting type: T.Type
    ) async throws -> APIResult<T> {
        let (data, response) = try await session.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }

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

🎮 How to use it

@MainActor
func fetchUser() async {
    do {
        let url = URL(string: "https://api.example.com/user")!
        let result: APIResult<User> = try await APIClient.shared.request(url: url, expecting: User.self)

        switch result {
        case .success(let user):
            print("Welcome \(user.name)")
        case .failure(let code, let message):
            print("Error \(code): \(message)")
        case .unknown:
            print("Unexpected server response 🌀")
        }
    } catch {
        print("Network error: \(error.localizedDescription)")
    }
}

🧪 Why is this approach useful?

  • You have a single structure to handle different response types.
  • The switch forces you to cover all cases (ideal for avoiding bugs).
  • You can reuse this pattern for any endpoint that follows the same convention.

✅ Summary

If your API responds with multiple structures and all use HTTP 200, using an enum with associated values is the cleanest, most Swifty way to handle it:

  • 🎯 success(T)
  • failure(code, message)
  • 🌀 unknown

This gives you a single entry point, without duplicating models or repeating logic.