Codable feels like magic at first. Add : Codable five letters to a struct and JSON conversion appears for free. The magic fades when you connect a real API: the server uses snake_case while Swift uses camelCase, date formats vary by API, and one malformed item can make the entire list decode fail, leaving the screen blank.
Part 6 of the intermediate series dives into Codable. We cover what synthesis actually generates and standard solutions for four situations you will inevitably meet in production: keys, dates, nesting, and partial failure.
The magic revealed — code the compiler writes for you
Codable is Encodable & Decodable’s typealias, combining protocols that require a type to describe how it writes itself to an encoder and how it is created from a decoder. What looks like magic comes from compiler synthesis. If every stored property is Codable, the compiler writes two things for you.
First, a CodingKeys enum: a list of keys whose cases mirror the property names. Second, init(from:) and encode(to:) implementations that read and write each property through those keys. Codable customization is therefore about which generated artifact you replace by hand. If only keys differ, use CodingKeys; if the structure differs, implement init(from:) too. Once you see this framework, the rest becomes a spectrum of how much code to write manually.
One more important fact: Codable is not JSON-specific. Because encoders and decoders are interchangeable, you can use PropertyListEncoder for plist or third-party libraries for XML and YAML. The type only knows how it is represented; the encoder decides the format. That is separation of concerns.
Situation 1. Different key names — CodingKeys and key strategies
If the server sends user_name but you want the property to be userName, the fix has two levels.
If the convention is consistent globally, one decoder setting is enough: decoder.keyDecodingStrategy = .convertFromSnakeCase. Every key is automatically converted from snake_case to camelCase. If the entire API follows that convention, this is the right answer.
If the rules are inconsistent or you want to rename a key explicitly, define CodingKeys yourself.
struct User: Codable {
let userName: String
let signupDate: Date
enum CodingKeys: String, CodingKey {
case userName = "user_nm" // server's legacy key
case signupDate = "created"
}
}
There is one useful side effect to remember: omitting a case from CodingKeys excludes that property from encoding and decoding. This is useful for local-only state such as cache flags, but excluded properties must have default values.
Situation 2. Dates — a minefield of formats
Date is the most common Codable trap in production. JSON has no single date standard, so servers use everything from Unix timestamps (1720000000) to ISO 8601 (“2026-07-15T09:30:00Z”) and custom formats (“2026-07-15 09:30”).
Set the decoder’s dateDecodingStrategy to match the server format: .secondsSince1970, .iso8601, or .formatted(formatter) for a custom format. Custom DateFormatter use has two traps. Without fixing locale to en_US_POSIX, parsing can break with the user’s 12/24-hour setting. Also, the default .iso8601 fails when the server sends ISO 8601 with milliseconds; enable the milliseconds option on ISO8601DateFormatter. These two traps explain most of the mysterious bugs where date parsing fails only for certain users.
If fields within one API use different formats, the practical options are to receive that field as String and convert it through a computed property, or branch with the .custom strategy.
Situation 3. Nesting and structural mismatches — the server’s shape versus mine
Sometimes the server wraps JSON in several layers, such as {"data": {"user": {...}}}. The simplest fix is to model the envelope as-is: mirror the server structure with struct Envelope: Codable { let data: DataBox }, then extract the value at the call site with envelope.data.user. It is explicit and easy to debug, making it the better production default.
If you want your model to differ from the server—for example, a flat User—implement init(from:) and drill through the hierarchy with nestedContainer. The code grows, but the model becomes cleaner and domain-centered. The deciding factor is how widely the model is used. A core app-wide model may justify a handwritten init(from:); a one-screen response is more economically handled by mirroring the envelope.
Situation 4. Partial failure — when one item kills everything
This is the most painful production case. If one required field is null in a list of 100 products, decoding the entire array throws and the screen goes blank. As covered in error handling, thrown errors propagate.
The first line of defense is optionality. Declare fields the server may omit honestly as let thumbnail: URL?. The principle that optional types encode “may be absent” applies directly to model design.
The structural defense is a failure-tolerant wrapper. A generic wrapper that turns element-decoding failures into nil is a standard pattern.
struct FailableItem<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws {
value = try? T(from: decoder) // nil on failure nil
}
}
let items = try decoder.decode([FailableItem<Product>].self, from: data)
.compactMap(\.value) // keep only successful items
You can see the combination of try? and compactMap, the two tools covered earlier. It expresses the policy “discard one bad item and keep the rest” as a type. One warning: this pattern silently swallows failures. In production, log the failure count so server-data problems do not stay hidden.
Debugging — DecodingError already has the answer
Finally, here is how to investigate a decoding failure. Catching try decoder.decode(...) catches DecodingError, which is more helpful than expected. Each of its four cases—keyNotFound, typeMismatch, valueNotFound, and dataCorrupted—contains which key failed, at which codingPath, what was expected, and what arrived.
When decoding fails, make a habit of inspecting print(error) rather than print(error as? DecodingError); at minimum, print codingPath by case in the catch block during development. This greatly reduces debugging time. In most “JSON parsing failed” issues, the answer is already inside the error.
Summary
- Codable’s magic is compiler synthesis. It writes the CodingKeys enum and init(from:)/encode(to:) for you; customization is simply deciding how much to replace by hand.
- Keys: for consistent snake_case, one keyDecodingStrategy line; for irregular names, declare CodingKeys manually. Omitting a case excludes that field from conversion.
- Dates: specify dateDecodingStrategy for the server format, and watch for the en_US_POSIX locale and milliseconds ISO 8601 traps.
- Nesting: mirror the envelope by default; flatten only core models with init(from:) + nestedContainer.
- Partial failure: honest optional declarations + the FailableItem pattern (try? + compactMap), with swallowed failures made visible through logging.
- Debugging: the answer is in DecodingError’s codingPath.
Next time: the property-wrapper principle behind at-sign syntax such as @State and @Published. We will build the “syntax that wraps stored properties” previewed in the property episode.

![Cover image for [Swift Intermediate #6] Advanced Swift Codable: A Field Guide to Four Traps](/assets/images/posts/c82f52ea-3b48-40d5-8739-a94bd38731cf/swift-codable-deep-dive-1.jpg)