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.
- ✅ Success (with data)
- ❌ Error (with code and message)
- 🌀 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
switchforces 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.