Understanding JSON → Swift
Codable, and the small print.
Why a Swift JSON DTO is usually three lines, and the CodingKeys override that's almost always required.
Codable is two protocols.
Codable is a typealias for Encodable & Decodable — two protocols that, when conformed to, let the compiler synthesize JSON encoding and decoding automatically. Mark your type Codable and the compiler does the work; mark it Decodable only and you save the encode-side synthesis. For a typical DTO, struct User: Codable {...} is enough to encode and decode.
Convention mismatch — CodingKeys.
Swift convention is camelCase property names; JSON keys are typically snake_case. The synthesis matches names verbatim — user_id in JSON looks for a property called user_id in Swift, which violates the language style. The fix is either a global key-decoding strategy on the decoder (.convertFromSnakeCase) or a per-type CodingKeys enum that maps the names. The decoder strategy is cleaner if all of your DTOs follow the same convention; the per-type enum is needed when names can't be derived automatically.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null }: struct User: Codable {
let userId: Int
let name: String
let avatar: String?
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case name, avatar
}
} Five lines of struct, three lines of CodingKeys. Or alternatively configure the decoder once: decoder.keyDecodingStrategy = .convertFromSnakeCase and drop the CodingKeys enum entirely.
With per-type CodingKeys
manual key map
Required when one or two keys diverge from convention.
enum CodingKeys: String, CodingKey { case userId = 'user_id' ... }
= Explicit mapping per property
With decoder strategy
decoder.keyDecodingStrategy = .convertFromSnakeCase
Set once on the JSONDecoder; applies to every type.
Drop the CodingKeys enum
= Cleaner DTOs
Optional vs implicitly-unwrapped.
A JSON field that's sometimes absent or null should be a Swift optional ( String?). Implicitly-unwrapped optionals (String!) work for "the value is always present after init, but I can't prove it to the compiler" — a pattern that has its place for outlets and post-init lifecycle but is wrong for JSON-decoded data. Use real optionals; rely on the type system to make absence explicit at the call site.
Dates and the date strategy.
Dates are the trickiest cross-language JSON field. Swift's Date can be encoded as ISO 8601, Unix epoch seconds, milliseconds, or a custom format. The decoder picks based on dateDecodingStrategy: .iso8601 is the right default for modern APIs. If the server is using a non-standard format, set a custom DateFormatter on the strategy. Get this wrong and every date in your data is silently shifted by hours or rejected entirely.
Custom decoding via init(from decoder:).
Synthesized decoding handles 95 % of cases. For the rest — fields that need transformation during decode, conditional logic on the JSON shape, polymorphic types that pick a subclass from a discriminator — implement init(from decoder: Decoder) throws by hand. The boilerplate is more than the synthesized version but you get full control. Codegen tools sometimes emit custom decode for tagged unions; for plain DTOs they always lean on synthesis.
Sendable on modern Swift.
Swift 5.5+ added Sendable — a marker protocol indicating a type is safe to pass across concurrency boundaries. Value types whose stored properties are also Sendable get the conformance automatically. Mark your DTOs struct User: Codable, Sendable and they work correctly with async/await and actors. Reference types, mutable shared state, and any class without explicit Sendable conformance need more thought.