Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 028 / 100

Codable in Swift: Swift & iOS best practices explained

JSON encoding and decoding.

Expona Admin8 min read

Codable in Swift: Swift & iOS best practices explained

JSON is everywhere in modern mobile apps—whether you're fetching user data from an API, saving local cache, or syncing with a backend service. Swift's Codable protocol makes encoding and decoding JSON feel almost effortless, but in production we often run into mismatched naming conventions, nested structures, and edge cases that catch us off guard. Understanding how to handle these scenarios properly is what separates fragile code from robust, maintainable systems.

Why Codable matters in production

Before Swift 4.0, JSON serialization meant wrestling with JSONSerialization or third-party libraries, manually casting dictionaries and handling type conversions. Codable changed that by letting you write less boilerplate while gaining type safety and compiler support. When you conform a model to Codable, you're declaring a contract: this type can be converted to and from external representations without runtime guessing.

In a real e-commerce app, you might receive order data from your API that doesn't match your internal naming scheme. Or a trading platform may send timestamps in an unusual format. Mastering Codable means you handle these variations cleanly, at the point of deserialization, rather than scattering fixes throughout your view logic.

Conform models to Codable

The simplest case is often the best starting point. When your JSON keys match your property names exactly, conforming to Codable requires nothing more than a declaration:

swift
struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let isActive: Bool
}

// Decoding from JSON
let jsonData = """
{
    "id": 42,
    "name": "Alice",
    "email": "alice@example.com",
    "isActive": true
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: jsonData)
print(user.name) // "Alice"

// Encoding back to JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let encoded = try encoder.encode(user)
print(String(data: encoded, encoding: .utf8)!)

Swift synthesizes the Codable conformance automatically when all properties are also Codable. This zero-cost abstraction is powerful—no runtime reflection, no magic, just straightforward code generation by the compiler.

In team projects, always verify that every property is Codable. If you add a non-Codable type—say, a custom UIImage or a closure—the struct won't compile until you either remove it or provide custom encoding logic.

CodingKeys for different names

APIs rarely follow Swift naming conventions. You might receive user_id or userId from a legacy backend, or a field called displayName that your team prefers to call name internally. CodingKeys lets you map external names to internal properties without renaming your actual model:

swift
struct Product: Codable {
    let id: Int
    let name: String
    let retailPrice: Double
    let inStock: Bool

    enum CodingKeys: String, CodingKey {
        case id
        case name = "product_name"
        case retailPrice = "retail_price"
        case inStock = "in_stock"
    }
}

let jsonData = """
{
    "id": 101,
    "product_name": "Wireless Headphones",
    "retail_price": 129.99,
    "in_stock": true
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
let product = try decoder.decode(Product.self, from: jsonData)
print(product.name) // "Wireless Headphones"
print(product.retailPrice) // 129.99

The CodingKeys enum mirrors your properties; properties with no explicit mapping use their property name as the JSON key. This approach is explicit and fails at compile time if you misspell a key name, rather than silently producing nil values at runtime.

In a CRM system where you're integrating with an older API, you often see field names that don't map one-to-one. Using CodingKeys keeps your model clean while clearly documenting the transformation in one place.

keyDecodingStrategy for snake_case

If every property in the JSON uses snake_case and your Swift model uses camelCase (which is idiomatic Swift), mapping each one individually is tedious. That's where keyDecodingStrategy comes in. It applies a transformation rule across all keys during decoding:

swift
struct Customer: Codable {
    let customerId: Int
    let firstName: String
    let lastName: String
    let emailAddress: String
    let createdAt: Date
}

let jsonData = """
{
    "customer_id": 5,
    "first_name": "Bob",
    "last_name": "Smith",
    "email_address": "bob@example.com",
    "created_at": "2024-01-15T10:30:00Z"
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601

let customer = try decoder.decode(Customer.self, from: jsonData)
print(customer.firstName) // "Bob"
print(customer.emailAddress) // "bob@example.com"

The convertFromSnakeCase strategy automatically converts snake_case JSON keys to camelCase Swift properties. This is a game-changer for teams consuming REST APIs from Python or Go backends, which typically use snake_case.

Note that this strategy is not reversible by default. If you're encoding a Swift model back to JSON and want snake_case output, you'd set keyEncodingStrategy = .convertToSnakeCase on the encoder. Strategies simplify repetitive transformations without boilerplate, but they apply globally—you can't use the strategy for one field and not another without custom coding.

Custom decoding for unusual formats

Sometimes the API sends data in a format that doesn't fit neatly into a strategy. Timestamps might use Unix milliseconds, prices might be strings, or nested arrays might need flattening. When the built-in strategies aren't enough, you implement Decodable manually:

swift
struct Order: Decodable {
    let id: Int
    let status: String
    let totalAmount: Decimal
    let items: [OrderItem]
    let createdAt: Date

    private enum CodingKeys: String, CodingKey {
        case id
        case status
        case totalAmount = "total"
        case items
        case createdAt = "created_timestamp"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        id = try container.decode(Int.self, forKey: .id)
        status = try container.decode(String.self, forKey: .status)
        items = try container.decode([OrderItem].self, forKey: .items)
        
        // Price comes as a string, convert to Decimal
        let priceString = try container.decode(String.self, forKey: .totalAmount)
        guard let price = Decimal(string: priceString) else {
            throw DecodingError.dataCorruptedError(
                forKey: .totalAmount,
                in: container,
                debugDescription: "Expected valid decimal string"
            )
        }
        totalAmount = price
        
        // Timestamp is Unix milliseconds
        let timestamp = try container.decode(Int64.self, forKey: .createdAt)
        createdAt = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000)
    }
}

struct OrderItem: Codable {
    let productId: Int
    let quantity: Int
    let price: Decimal
}

let jsonData = """
{
    "id": 9001,
    "status": "shipped",
    "total": "249.99",
    "items": [
        {"product_id": 101, "quantity": 2, "price": 49.99},
        {"product_id": 102, "quantity": 1, "price": 150.01}
    ],
    "created_timestamp": 1705319400000
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
let order = try decoder.decode(Order.self, from: jsonData)
print(order.totalAmount) // 249.99
print(order.createdAt) // 2024-01-15 10:30:00 +0000

Custom decoding gives you full control. You decode each field manually, apply transformations, and throw detailed errors if something goes wrong. This verbosity is worthwhile when you're handling complex or non-standard formats that will be used throughout the app.

In a ride-hailing platform, you might receive pricing with nested discounts and surges that need aggregation before storing. A custom decoder lets you handle that logic once, at the boundary, rather than scattering it into multiple view models.

For Encodable conformance, you'd implement encode(to:) in a similar fashion, using KeyedEncodingContainer. If a model is both Decodable and Encodable, it conforms to Codable.

Common mistakes

  • Forgetting to handle optionals: If a field might be missing in the JSON, declare the property as optional in your model. Without this, decoding fails with a keyNotFound error. Use decodeIfPresent in custom decoders for optional fields.
  • Mixing automatic and manual conformance: If you declare CodingKeys but don't implement init(from:), the compiler generates the default implementation using your keys. However, if you implement custom decoding, be careful not to accidentally override behavior you expected to be automatic.
  • Ignoring date formats: Dates require explicit strategy handling or custom decoding. The default dateDecodingStrategy is ISO 8601, but APIs often use Unix timestamps or other formats. Always set the strategy or handle it manually.
  • Not validating after decoding: A model might successfully decode but still be invalid for your business logic (e.g., negative price, empty name). Consider implementing a validation method separate from decoding, or throw in init(from:) if validation is critical.
  • Creating decoders and encoders repeatedly: Instantiating JSONDecoder()` or JSONEncoder() for every operation is wasteful. Reuse them across your app, or make them properties of a service layer. Configuration like dateDecodingStrategy is set once and applied to all subsequent decodings.

Key takeaways

  • Use automatic Codable synthesis when your model's properties match the JSON exactly and are themselves Codable.
  • Reach for CodingKeys to map specific properties when field names differ between JSON and your model.
  • Apply keyDecodingStrategy = .convertFromSnakeCase to handle systematic naming differences across all fields, reducing boilerplate.
  • Implement custom init(from:) and encode(to:) for complex transformations like type conversions, timestamp parsing, or nested data flattening.
  • Set decoder and encoder configurations (strategies, date formats) once in a centralized place—a service layer or utility—and reuse those instances.
  • Always handle optional fields explicitly and throw meaningful errors during decoding so debugging is straightforward.
  • Validate decoded models for business logic constraints separately from the decoding process to keep concerns cleanly separated.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles