Custom Codable: nested containers and hostile JSON
When synthesis stops matching the wire — init(from:) by hand, nested containers, polymorphic payloads, and money that must never touch a Double.
By the end you will be able to
- Write init(from:) and encode(to:) by hand with keyed and nested containers
- Flatten nested JSON into flat models instead of mirroring wire types
- Decode polymorphic arrays via a type discriminator, with an unknown-case escape
- Choose the money representation at the API boundary and defend it in review
A payments API documents its amounts as JSON strings — "amount": "19.99" — and a teammate opens a ticket asking the backend to "send a real number instead". What is the string buying?
Where synthesis stops
The synthesised-protocols lesson (S2-06) earned you free Codable: matching names decode themselves, CodingKeys renames fields, .convertFromSnakeCase handles the common case wholesale. For your own backend, where you control both sides, that is usually the whole story.
Then you integrate a real third-party API — a banking aggregator, a payments processor, a brokerage feed — and synthesis hits three walls in the first hour:
- Shape mismatch. The wire nests things your model doesn't want:
{"amount": {"value": "19.99", "currency": "GBP"}}where your model wantsamountandcurrencyas flat properties. Synthesis can only mirror the nesting — and mirroring means your whole codebase imports the wire format's awkwardness. - Polymorphism. One array, many shapes: a transactions feed where each element is a card payment, a transfer, or a fee, discriminated by a
"type"field. Synthesis has no answer at all. - Representation. The wire type isn't the model type: money as strings (for the pretest's reason), dates as epoch seconds, booleans as
"Y"/"N". Something must transform at the boundary.
The escape hatch is the same for all three: stop letting the compiler write init(from:) and write it yourself. The API is small — the skill is knowing the containers.
What a decoder actually hands you
init(from decoder: Decoder) throws receives a cursor into the payload, and you read it through containers — typed views of the current position:
decoder.container(keyedBy:)— a keyed container: the fields of a JSON object, accessed byCodingKey.decoder.unkeyedContainer()— an unkeyed container: the elements of a JSON array, in order.decoder.singleValueContainer()— one bare value (a wrapper type like aUserIDthat is just a string on the wire).
The move that solves shape mismatch is asking a keyed container for a nested container — descending into a sub-object without declaring a Swift type for it:
struct Transaction: Decodable {
let id: String
let amount: Decimal
let currency: String
private enum CodingKeys: String, CodingKey { case id, amount }
private enum AmountKeys: String, CodingKey { case value, currency }
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
// Descend into {"amount": {...}} — no AmountPayload struct anywhere:
let amountBox = try container.nestedContainer(keyedBy: AmountKeys.self, forKey: .amount)
currency = try amountBox.decode(String.self, forKey: .currency)
let raw = try amountBox.decode(String.self, forKey: .value)
guard let parsed = Decimal(string: raw) else {
throw DecodingError.dataCorruptedError(
forKey: .value, in: amountBox,
debugDescription: "Expected a decimal string, got '\(raw)'")
}
amount = parsed
}
}
Two things to notice. The wire's nesting stayed in this one file — every other file sees a flat Transaction with the types the domain wants. And the failure path throws with a description, because the networking lesson's point about JSONDecoder errors naming the exact key path only holds if your hand-written paths keep up the standard.
The runtime here has no JSONDecoder, so to make the machinery visible we'll build the semantics in miniature — a JSON tree as an enum, and a keyed container that tracks its path the way the real one does:
missing: txn.amount.valeu is a thirty-second diagnosis. A decoder that returned nil instead would have produced "Something went wrong" three layers up — the exact anti-pattern the networking lesson warned about, now visible from the inside: the path accumulates because each nested call extends it, which is precisely what the real DecodingError context does.
Money at the boundary
You know from the values lesson why Double cannot hold 19.99. At the API boundary that principle becomes a wire contract — one of two safe shapes:
- Integer minor units —
"amount_minor": 1999. Decodes straight intoInt; arithmetic is exact; nothing to parse. This is Stripe's contract, and the best one when you control the API. - Decimal string —
"amount": "19.99". Decodes viaDecimal(string:)as in theTransactionabove. This is what aggregators and banking APIs commonly send, because it survives any client's parser untouched.
And one bug shape: a bare JSON number decoded into Double — or laundered through Double on its way to something better. Decimal(19.99) (the Double initialiser) already contains the contamination; Decimal(string: "19.99") never touches it. In review, the question "what type does this amount pass through?" needs an answer with no Double anywhere in it.
decodeStringIfPresent models the real decodeIfPresent contract. Four rows: present, absent, null, and wrong-type. What prints?
enum JSON {
case string(String)
case number(Double)
case null
}
enum DecodeError: Error {
case typeMismatch(String)
}
func decodeStringIfPresent(_ fields: [String: JSON], _ key: String) throws -> String? {
guard let value = fields[key] else { return nil } // absent: forgiven
switch value {
case .string(let s): return s
case .null: return nil // null: forgiven
default: throw DecodeError.typeMismatch(key) // wrong type: LOUD
}
}
let rows: [[String: JSON]] = [
["nickname": .string("Ash")],
[:],
["nickname": .null],
["nickname": .number(7)],
]
for row in rows {
do {
let nickname = try decodeStringIfPresent(row, "nickname")
print(nickname ?? "none")
} catch DecodeError.typeMismatch(let key) {
print("typeMismatch: \(key)")
}
}Polymorphic payloads: one array, many shapes
The transactions feed. Every element has a "type", and the rest of the fields depend on it. The enums lesson (S2-03) gave you the perfect Swift shape for "one of several forms" — an enum with associated values — and the decode recipe is: read the discriminator, switch, hand the same decoder to the branch:
enum TransactionEvent: Decodable {
case cardPayment(CardPayment)
case transfer(Transfer)
case unknown(type: String)
private enum CodingKeys: String, CodingKey { case type }
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "card_payment": self = .cardPayment(try CardPayment(from: decoder))
case "transfer": self = .transfer(try Transfer(from: decoder))
default: self = .unknown(type: type)
}
}
}
The default line is not a formality — it is the forward-compatibility contract. The server will add "cashback_boost" while your app is on someone's phone for two years. An app that throws on unrecognised types turns every backend launch into a client incident; .unknown renders as a generic row (or is filtered out), and the count of them is a metric worth logging.
Here is the shape running, over the miniature JSON:
Walk the last line: cashback_boost shipped server-side yesterday, this build has never heard of it, and the feed still renders. That is the difference between "the backend can evolve" and "every backend change is coordinated with an app release and a review cycle" — a distinction interviewers at API-driven companies probe directly.
When one bad element arrives
An unkeyed container decodes arrays element by element — so by default, one malformed element fails the whole array, and one bad row from a partner bank hides fifty good ones behind an error screen. The resilient pattern wraps each element in a type whose decode cannot fail, capturing the result either way:
struct Throwable<T: Decodable>: Decodable {
let result: Result<T, Error>
init(from decoder: Decoder) throws {
result = Result { try T(from: decoder) } // catches instead of throwing
}
}
// Decode all, keep the good, count the bad:
let wrapped = try JSONDecoder().decode([Throwable<TransactionEvent>].self, from: data)
let events = wrapped.compactMap { try? $0.result.get() }
let dropped = wrapped.count - events.count // log and measure this
Because Throwable.init never actually throws, the array's iteration always advances — each slot "succeeds", carrying either a value or the error that explains it.
A malformed element arrives in (a) the transactions feed screen, and (b) a statement export used for reconciliation. The Throwable wrapper could silently drop it in both. Where is that acceptable?
The way back out, and dates
encode(to:) is the mirror image — same containers, encode instead of decode — and the same boundary rules apply on the way out: if amounts arrived as strings, they leave as strings. But don't write what you don't need: conforming a response-only type to Encodable is speculative API. Most apps decode many types and encode few (request bodies, local persistence).
Dates get the same representation treatment as money, one level up: JSONDecoder has a dial for the whole payload —
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601 // "2026-07-31T09:30:00Z"
// or .secondsSince1970, or .custom { ... } for the API that sends THREE formats
— and .custom is the escape hatch when a legacy API mixes formats. The strategy is decided once, where the decoder is configured (the networking lesson put that in the client type); init(from:) handles per-field weirdness, strategies handle payload-wide conventions.
Parse money like a clearing house
Amounts arrive as decimal strings. Convert them to integer minor units — with no Double anywhere, because this function is the boundary the rest of the codebase trusts.
Decode the feed, survive the future
Implement decode(_:) for a discriminated feed: two known types with required fields, plus the two failure modes — an unknown type (skip gracefully) and a known type missing a required field (malformed).
Design the Codable layer for a banking app consuming a third-party aggregator: accounts, a mixed transactions array (five known types, growing), amounts, and timestamps in two formats across endpoints. Decide: the money representation end to end, where nestedContainer earns its keep versus mirror structs, the unknown-transaction-type story, which paths get Throwable resilience and which must fail loudly, and what one metric tells you the boundary is degrading in production. Write it as the design-review comment you'd leave.
You can now:
- Hand-write
init(from:)with keyed, unkeyed and nested containers — keeping wire shapes out of your models - Hold the money line: minor units or decimal strings into
Decimal, and noDoubleanywhere in the path - Decode discriminated unions with a forward-compatible
.unknownarm - Choose lossy versus loud per surface, and say why the export must abort
Next up: Swift Charts — turning the data you just decoded into the graphs a fintech actually ships.