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
CodingKeysandkeyDecodingStrategy- Custom
init(from:): Flattening Nested Containers - Polymorphic Decoding with a Type Discriminator
- Custom
encode(to:): Restructuring on the Way Out - Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
CodingKeysenum, 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., url → uRL 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:
KeyedDecodingContainer— for JSON objects ({ })UnkeyedDecodingContainer— for JSON arrays ([ ])SingleValueDecodingContainer— for primitive values
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
AnyPixarCharacterwrapper 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
Stringallocation, not theCodablesynthesis itself.
For large payloads, consider:
- Paginate server-side. The fastest decode is the one you don’t do. Request 50 items, not 5,000.
- Decode on a background task.
JSONDecoder.decodeis synchronous and CPU-bound — always wrap it inTask.detachedor a background actor. - 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)
| Scenario | Recommendation |
|---|---|
| Simple JSON with matching property names | Use synthesis — zero boilerplate, full correctness |
snake_case API keys, consistent naming | Use keyDecodingStrategy: .convertFromSnakeCase |
| A few key renames, everything else matches | Use a CodingKeys enum just for the mismatches |
| Nested objects to flatten into a struct | Use nestedContainer(keyedBy:) in a custom init(from:) |
| Polymorphic arrays with a type discriminator | Use 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 thread | Always move to a background context — blocks the main thread |
| Encoding back to the original nested API format | Implement encode(to:) alongside init(from:) |
Summary
CodingKeysenums map Swift property names to JSON keys — every property must be listed once you define the enum.keyDecodingStrategy: .convertFromSnakeCaseis 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.decodeIfPresentreturnsnilfor absent keys rather than throwing — prefer it overdecodefor 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.codingPathpinpoints 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.