Introduction

When I started working with third‑party APIs, I quickly realized that the JSON keys rarely match the neat property names we prefer in Swift models. Rather than manually renaming keys in every decode step, I adopted a custom key strategy that keeps my models readable and the decoding logic reusable. This technique has become a staple in my daily workflow, saving time and reducing boilerplate.

The Problem with Straightforward Codable

Swift’s built‑in `Codable` protocol makes it trivial to encode and decode structures, but it expects a one‑to‑one mapping between property names and JSON keys. If an API returns fields like `first_name`, `last_name`, and `date_of_birth`, you end up with a model that looks like:

struct User {
    let firstName: String
    let lastName: String
    let dateOfBirth: Date
}

Decoding requires a custom `CodingKeys` enum that mirrors the JSON keys, which quickly becomes verbose when you have many models and similar transformations across the project. The repetitive work not only clutters the code but also makes it harder to keep in sync when the API changes.

A Practical Solution: Custom Key Strategies

The cleanest approach is to let Swift’s decoding machinery handle the renaming automatically. You can achieve this with a simple wrapper that implements the `CodingKey` protocol, or you can use the more straightforward method provided by Swift’s `KeyedDecodingContainer`‑compatible key strategy. The trick is to define a custom `CodingKeys` enum that maps each JSON key to your preferred property name, and then use `init(from decoder: Decoder) throws` to apply the transformation.

Here is a reusable pattern I keep in a helper file:

import Foundation

struct APIUser: Codable {
    // These are the properties we want in our domain model
    let firstName: String
    let lastName: String
    let birthDate: Date

    enum CodingKeys: String, CodingKey {
        case first_name
        case last_name
        case date_of_birth = "date_of_birth"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // Map JSON keys to our property names manually
        self.firstName = try container.decode(String.self, forKey: .first_name)
        self.lastName = try container.decode(String.self, forKey: .last_name)
        // Custom date formatting if needed
        let dateString = try container.decode(String.self, forKey: .date_of_birth)
        let formatter = ISO8601DateFormatter()
        self.birthDate = try formatter.date(from: dateString) ?? Date()
    }
}

The `CodingKeys` enum declares the exact keys we receive from the API, while the `init(from:)` method translates them into our internal property names. This keeps the model clean, allows us to add any transformation logic (like date parsing) in one place, and still works with `Codable`‑based APIs such as URLSession, JSONPlaceholder, or even Core Data migrations.

Real‑World Use Case: Onboarding Flow

During the onboarding of new users, our backend returns a payload like:

{ "first_name": "Jane", "last_name": "Doe", "date_of_birth": "1995-08-23T00:00:00Z", "email": "jane.doe@example.com" }

Instead of writing a separate mapping function each time we need a `UserProfile` object, I defined a generic helper that can be reused across the onboarding service, the user settings view, and even the analytics module. The same pattern works for nested structures as well; you just nest the `CodingKeys` enums and call `try container.nestedContainer(...)`.

Because the decoding logic lives inside the model, any changes to the API contract only require updates to the `CodingKeys` enum or the transformation inside the `init`. The rest of the code that consumes the model stays untouched, which dramatically reduces regression risk.

Tips and Gotchas

  • Keep the mapping explicit. It’s tempting to rely on automatic key transformation, but explicit mapping makes the contract between API and model crystal clear.
  • Reuse your key strategy. If you have multiple models that consume the same JSON shape, consider extracting a common protocol or a helper method to avoid duplication.
  • Handle missing keys gracefully. Use `decodeIfPresent` for optional fields, or provide default values inside the `init` to prevent runtime crashes.
  • Avoid heavy logic in the initializer. While you can perform complex transformations, keep the `init(from:)` focused on decoding and simple formatting. Complex business logic belongs elsewhere.
  • Consider third‑party libraries. Projects like ObjectMapper or Alamofire provide more expressive key transformations, but for most use cases the native approach is sufficient and adds no dependencies.

Conclusion

Custom key strategies give you the best of both worlds: clean, self‑documenting model properties and a robust way to deal with unpredictable API naming. By centralizing the mapping logic inside each model’s `init(from:)`, you keep your view code simple, reduce boilerplate, and make future API adjustments painless. Adopt this pattern in your next Swift project, and you’ll notice the difference in both readability and maintainability.