Mastering `Codable` in Swift: Custom Keys, Nested Containers, and Dynamic Decoding


Auto-synthesized Codable works great for simple JSON — but real-world APIs send snake_case keys, nested objects that need flattening, polymorphic arrays, and fields that might be a string or an integer depending on context. When synthesis breaks, most engineers reach for CodingKeys and stop — missing the full power of Decoder’s container API that makes even the messiest JSON tractable.

This post covers the complete toolkit: CodingKeys for key renaming, keyDecodingStrategy as a shortcut, nestedContainer for flattening, decodeIfPresent for optional fields, polymorphic decoding with a type discriminator, custom encode(to:), and DecodingError introspection. We won’t cover Combine publishers or async networking layers — those have their own posts.

Contents

The Problem

Here’s a real-world Pixar film API response — the kind of JSON that breaks synthesis immediately:

{
  "film_title": "Toy Story",
  "release_year": 1995,
  "box_office": {
    "domestic": 191796233,
    "worldwide": 373554033
  },
  "characters": [
    { "type": "cowboy", "name": "Woody", "catchphrase": "There's a snake in my boot!" },
    { "type": "space_ranger", "name": "Buzz Lightyear", "laser_color": "red" }
  ],
  "director": "John Lasseter",
  "runtime_minutes": 81
}

The naively synthesized Swift struct fails on multiple fronts:

// ❌ Synthesis fails here — property names don't match JSON keys
struct PixarFilm: Codable {
    let filmTitle: String       // JSON key is "film_title"
    let releaseYear: Int        // JSON key is "release_year"
    let domesticRevenue: Int    // Lives at box_office.domestic — no synthesis
    let worldwideRevenue: Int   // Lives at box_office.worldwide — no synthesis
    let characters: [???]       // Polymorphic — different keys per object
    let director: String        // Fine
    let runtimeMinutes: Int     // JSON key is "runtime_minutes"
}

Three distinct problems here: key name mismatch, nested container flattening, and polymorphic arrays. Let’s solve them in order.

Apple Docs: Codable — Swift Standard Library

CodingKeys and keyDecodingStrategy

Manual CodingKeys Enum

A CodingKeys enum conforming to CodingKey tells the synthesized decoder which JSON key maps to which Swift property. You only need to list the properties whose names differ from their JSON keys:

struct PixarFilm: Codable {
    let filmTitle: String
    let releaseYear: Int
    let director: String
    let runtimeMinutes: Int

    enum CodingKeys: String, CodingKey {
        case filmTitle = "film_title"
        case releaseYear = "release_year"
        case director
        case runtimeMinutes = "runtime_minutes"
    }
}

Warning: Once you define a CodingKeys enum, synthesis is all-or-nothing. Every stored property on the type must appear in the enum — omit one and the compiler silently stops synthesizing that property rather than emitting an error. Double-check the enum covers every property, including those whose names already match.

keyDecodingStrategy as a Shortcut

When an entire API uses snake_case consistently, .convertFromSnakeCase is less work than a full CodingKeys enum:

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

// Now: film_title → filmTitle, release_year → releaseYear, etc.
let film = try decoder.decode(PixarFilm.self, from: jsonData)

The strategy applies recursively to all nested types. Use CodingKeys for exceptions where the conversion produces the wrong name (e.g., urluRL is a known edge case with .convertFromSnakeCase).

Apple Docs: JSONDecoder.KeyDecodingStrategy — Foundation

Custom init(from:): Flattening Nested Containers

The box_office object in our JSON is a nested container. We want to decode it into two flat properties on PixarFilm. Synthesis can’t do this — you need a custom init(from:).

Decoder gives you access to three container types:

Here’s the complete PixarFilm struct with flattened box office numbers and optional fields:

struct PixarFilm: Decodable {
    let filmTitle: String
    let releaseYear: Int
    let director: String
    let runtimeMinutes: Int
    let domesticRevenue: Int
    let worldwideRevenue: Int
    let tagline: String?  // Optional — not present in all responses

    // Top-level JSON keys
    private enum CodingKeys: String, CodingKey {
        case filmTitle = "film_title"
        case releaseYear = "release_year"
        case director
        case runtimeMinutes = "runtime_minutes"
        case boxOffice = "box_office"
        case tagline
    }

    // Keys nested inside "box_office"
    private enum BoxOfficeCodingKeys: String, CodingKey {
        case domestic
        case worldwide
    }

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

        filmTitle = try container.decode(String.self, forKey: .filmTitle)
        releaseYear = try container.decode(Int.self, forKey: .releaseYear)
        director = try container.decode(String.self, forKey: .director)
        runtimeMinutes = try container.decode(Int.self, forKey: .runtimeMinutes)
        tagline = try container.decodeIfPresent(String.self, forKey: .tagline)

        // Descend into the "box_office" nested object
        let boxOfficeContainer = try container.nestedContainer(
            keyedBy: BoxOfficeCodingKeys.self,
            forKey: .boxOffice
        )
        domesticRevenue = try boxOfficeContainer.decode(Int.self, forKey: .domestic)
        worldwideRevenue = try boxOfficeContainer.decode(Int.self, forKey: .worldwide)
    }
}

decodeIfPresent is the key tool for optional fields. Unlike decode, it returns nil rather than throwing when the key is absent from the JSON. Use it for any field the API might omit.

nestedContainer(keyedBy:forKey:) is what makes flattening possible — it returns a new KeyedDecodingContainer scoped to the nested object, so you can decode its properties directly into your flat Swift struct.

Polymorphic Decoding with a Type Discriminator

The characters array is the hardest part of this JSON — each element has a different shape depending on its type field:

{ "type": "cowboy", "name": "Woody", "catchphrase": "There's a snake in my boot!" }
{ "type": "space_ranger", "name": "Buzz Lightyear", "laser_color": "red" }

The standard approach: decode the discriminator first, then decode the full payload into the appropriate concrete type.

// The shared protocol
protocol PixarCharacter {
    var name: String { get }
    var characterType: String { get }
}

struct CowboyCharacter: PixarCharacter, Decodable {
    let name: String
    let catchphrase: String
    var characterType: String { "cowboy" }
}

struct SpaceRangerCharacter: PixarCharacter, Decodable {
    let name: String
    let laserColor: String
    var characterType: String { "space_ranger" }

    enum CodingKeys: String, CodingKey {
        case name
        case laserColor = "laser_color"
    }
}

// The type-erased wrapper that performs the discriminated decode
struct AnyPixarCharacter: Decodable {
    let base: any PixarCharacter

    private enum DiscriminatorKey: String, CodingKey {
        case type
    }

    init(from decoder: Decoder) throws {
        let typeContainer = try decoder.container(keyedBy: DiscriminatorKey.self)
        let characterType = try typeContainer.decode(String.self, forKey: .type)

        switch characterType {
        case "cowboy":
            base = try CowboyCharacter(from: decoder)
        case "space_ranger":
            base = try SpaceRangerCharacter(from: decoder)
        default:
            throw DecodingError.dataCorruptedError(
                forKey: DiscriminatorKey.type,
                in: typeContainer,
                debugDescription: "Unknown character type '\(characterType)'."
            )
        }
    }
}

Add the characters property to PixarFilm and decode it from the top-level container:

// Inside PixarFilm.init(from:)
let characterWrappers = try container.decode([AnyPixarCharacter].self, forKey: .characters)
characters = characterWrappers.map(\.base)

The critical insight: AnyPixarCharacter.init(from:) receives the same decoder that positioned the cursor at that array element. Passing decoder directly into CowboyCharacter(from: decoder) re-decodes the same JSON object — this time with the concrete type’s full CodingKeys. No string parsing, no manual key extraction.

Note: Swift does not (as of Swift 6) synthesize polymorphic decoding. The AnyPixarCharacter wrapper pattern is the idiomatic solution. If you control the API, consider a discriminated union approach where the type field is an enum — the switch becomes exhaustive.

Custom encode(to:): Restructuring on the Way Out

When your app sends data back to an API that expects the original nested format, you need a custom encode(to:) to reconstruct the box_office object from the flat Swift properties:

extension PixarFilm: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(filmTitle, forKey: .filmTitle)
        try container.encode(releaseYear, forKey: .releaseYear)
        try container.encode(director, forKey: .director)
        try container.encode(runtimeMinutes, forKey: .runtimeMinutes)
        try container.encodeIfPresent(tagline, forKey: .tagline)

        // Reconstruct the nested "box_office" object
        var boxOfficeContainer = container.nestedContainer(
            keyedBy: BoxOfficeCodingKeys.self,
            forKey: .boxOffice
        )
        try boxOfficeContainer.encode(domesticRevenue, forKey: .domestic)
        try boxOfficeContainer.encode(worldwideRevenue, forKey: .worldwide)
    }
}

encodeIfPresent is the encode-side counterpart to decodeIfPresent — it omits the key entirely when the value is nil, producing cleaner JSON rather than "tagline": null.

Advanced Usage

UnkeyedDecodingContainer for Heterogeneous Arrays

When an array doesn’t have a type discriminator but contains mixed primitives, use UnkeyedDecodingContainer to iterate and decode each element manually:

// JSON: ["Toy Story", 1995, true, 91]
// Mixed array of title, year, hasSequel, runtime

var unkeyedContainer = try decoder.unkeyedContainer()
let title = try unkeyedContainer.decode(String.self)
let year = try unkeyedContainer.decode(Int.self)
let hasSequel = try unkeyedContainer.decode(Bool.self)
let runtime = try unkeyedContainer.decode(Int.self)

Two-Level Nesting with nestedContainer

Some APIs pack deeply nested structure. You can chain nestedContainer calls, but in practice, deep nesting is a signal to introduce an intermediate struct rather than chaining three levels deep. Intermediate structs are easier to test and reuse.

The JSONValue Pattern for Truly Dynamic JSON

When a field can be any JSON value (string, number, array, object), a recursive JSONValue enum is the pragmatic solution:

// Simplified for clarity
enum JSONValue: Codable {
    case string(String)
    case int(Int)
    case double(Double)
    case bool(Bool)
    case array([JSONValue])
    case object([String: JSONValue])
    case null

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let s = try? container.decode(String.self) { self = .string(s); return }
        if let i = try? container.decode(Int.self) { self = .int(i); return }
        if let d = try? container.decode(Double.self) { self = .double(d); return }
        if let b = try? container.decode(Bool.self) { self = .bool(b); return }
        if let a = try? container.decode([JSONValue].self) { self = .array(a); return }
        if let o = try? container.decode([String: JSONValue].self) {
            self = .object(o); return
        }
        if container.decodeNil() { self = .null; return }
        throw DecodingError.dataCorruptedError(
            in: container,
            debugDescription: "Cannot decode JSON value"
        )
    }
}

Reach for JSONValue when you genuinely don’t know the shape of a field at compile time. For fields with a finite set of shapes, the type-discriminator pattern above is safer.

DecodingError for Meaningful Error Messages

When decoding fails in production, the default DecodingError message is a wall of text. Extract the useful parts:

func decode<T: Decodable>(_ type: T.Type, from data: Data) -> Result<T, String> {
    do {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return .success(try decoder.decode(type, from: data))
    } catch let DecodingError.keyNotFound(key, context) {
        let path = context.codingPath.map(\.stringValue).joined(separator: ".")
        return .failure("Missing key '\(key.stringValue)' at \(path)")
    } catch let DecodingError.typeMismatch(expectedType, context) {
        let path = context.codingPath.map(\.stringValue).joined(separator: ".")
        return .failure("Type mismatch at \(path): expected \(expectedType)")
    } catch let DecodingError.valueNotFound(type, context) {
        let path = context.codingPath.map(\.stringValue).joined(separator: ".")
        return .failure("Null value for non-optional \(type) at \(path)")
    } catch let DecodingError.dataCorrupted(context) {
        return .failure("Corrupted data: \(context.debugDescription)")
    } catch {
        return .failure(error.localizedDescription)
    }
}

context.codingPath is the key — it gives you the exact path through the JSON where decoding failed (["characters", "0", "laser_color"]), which turns a 30-minute debugging session into a 30-second fix.

Apple Docs: DecodingError — Swift Standard Library

Performance Considerations

JSONDecoder is surprisingly slow for large payloads. Some context:

  • Decoding a 1 MB JSON array of 10,000 objects typically takes 50–150ms on an iPhone 14, depending on object complexity.
  • The cost is dominated by UTF-8 parsing and String allocation, not the Codable synthesis itself.

For large payloads, consider:

  1. Paginate server-side. The fastest decode is the one you don’t do. Request 50 items, not 5,000.
  2. Decode on a background task. JSONDecoder.decode is synchronous and CPU-bound — always wrap it in Task.detached or a background actor.
  3. Lazy decoding. Decode only the fields you need for the list view; decode the full model only when the user taps an item.
// Lightweight model for list view
struct PixarFilmSummary: Decodable {
    let filmTitle: String
    let releaseYear: Int
    enum CodingKeys: String, CodingKey {
        case filmTitle = "film_title"
        case releaseYear = "release_year"
    }
}
// Full PixarFilm model loaded only on detail view

Apple Docs: JSONDecoder — Foundation

When to Use (and When Not To)

ScenarioRecommendation
Simple JSON with matching property namesUse synthesis — zero boilerplate, full correctness
snake_case API keys, consistent namingUse keyDecodingStrategy: .convertFromSnakeCase
A few key renames, everything else matchesUse a CodingKeys enum just for the mismatches
Nested objects to flatten into a structUse nestedContainer(keyedBy:) in a custom init(from:)
Polymorphic arrays with a type discriminatorUse the AnyConcreteType wrapper pattern with a type switch
Truly dynamic JSON (unknown shape at compile time)Use JSONValue enum as a last resort
Large payloads decoded on the main threadAlways move to a background context — blocks the main thread
Encoding back to the original nested API formatImplement encode(to:) alongside init(from:)

Summary

  • CodingKeys enums map Swift property names to JSON keys — every property must be listed once you define the enum.
  • keyDecodingStrategy: .convertFromSnakeCase is the right shortcut when the entire API follows snake_case consistently.
  • nestedContainer(keyedBy:forKey:) lets you descend into nested JSON objects and decode their fields directly into a flat Swift struct.
  • decodeIfPresent returns nil for absent keys rather than throwing — prefer it over decode for any field the API might omit.
  • The discriminated union pattern (AnyConcreteType + switch on a type key) is the idiomatic solution for polymorphic arrays.
  • DecodingError.Context.codingPath pinpoints the exact location of a decode failure — always log it in production networking layers.

Now that you have full control over encoding and decoding, the logical next step is designing a clean networking layer that surfaces DecodingError alongside HTTP errors in a unified Result type.