| name | codable |
| description | Use when working with Codable protocol, JSON encoding/decoding, CodingKeys customization, enum serialization, date strategies, custom containers, or encountering "Type does not conform to Decodable/Encodable" errors - comprehensive Codable patterns and anti-patterns for Swift 6.x |
| skill_type | discipline |
| version | 1 |
Swift Codable Patterns
Comprehensive guide to Codable protocol conformance for JSON and PropertyList encoding/decoding in Swift 6.x.
Quick Reference
Decision Tree: When to Use Each Approach
Has your type...
├─ All properties Codable? → Automatic synthesis (just add `: Codable`)
├─ Property names differ from JSON keys? → CodingKeys customization
├─ Needs to exclude properties? → CodingKeys customization
├─ Enum with associated values? → Check enum synthesis patterns
├─ Needs structural transformation? → Manual implementation + bridge types
├─ Needs data not in JSON? → DecodableWithConfiguration (iOS 15+)
└─ Complex nested JSON? → Manual implementation + nested containers
Common Triggers
| Error | Solution |
|---|---|
| "Type 'X' does not conform to protocol 'Decodable'" | Ensure all stored properties are Codable |
| "No value associated with key X" | Check CodingKeys match JSON keys |
| "Expected to decode X but found Y instead" | Type mismatch; check JSON structure or use bridge type |
| "keyNotFound" | JSON missing expected key; make property optional or provide default |
| "Date parsing failed" | Configure dateDecodingStrategy on decoder |
Part 1: Automatic Synthesis
Swift automatically synthesizes Codable conformance when all stored properties are Codable.
Struct Synthesis
// ✅ Automatic synthesis
struct User: Codable {
let id: UUID // Codable
var name: String // Codable
var membershipPoints: Int // Codable
}
// JSON: {"id":"...", "name":"Alice", "membershipPoints":100}
Requirements:
- All stored properties must conform to Codable
- Properties use standard Swift types or other Codable types
- No custom initialization logic needed
Enum Synthesis Patterns
Pattern 1: Raw Value Enums
enum Direction: String, Codable {
case north, south, east, west
}
// Encodes as: "north"
The raw value itself becomes the JSON representation.
Pattern 2: Enums Without Associated Values
enum Status: Codable {
case success
case failure
case pending
}
// Encodes as: {"success":{}}
Each case becomes an object with the case name as the key and empty dictionary as value.
Pattern 3: Enums With Associated Values
enum APIResult: Codable {
case success(data: String, count: Int)
case error(code: Int, message: String)
}
// success case encodes as:
// {"success":{"data":"example","count":5}}
Gotcha: Unlabeled associated values generate _0, _1 keys:
enum Command: Codable {
case store(String, Int) // ❌ Unlabeled
}
// Encodes as: {"store":{"_0":"value","_1":42}}
Fix: Always label associated values for predictable JSON:
enum Command: Codable {
case store(key: String, value: Int) // ✅ Labeled
}
// Encodes as: {"store":{"key":"value","value":42}}
When Synthesis Breaks
Automatic synthesis fails when:
- Computed properties - Only stored properties are encoded
- Non-Codable properties - Custom types without Codable conformance
- Property wrappers -
@Published,@State(except@AppStoragewith Codable types) - Class inheritance - Subclasses must implement
init(from:)manually
Part 2: CodingKeys Customization
Use CodingKeys enum to customize encoding/decoding without full manual implementation.
Renaming Keys
struct Article: Codable {
let url: URL
let title: String
let body: String
enum CodingKeys: String, CodingKey {
case url = "source_link" // JSON uses "source_link"
case title = "content_name" // JSON uses "content_name"
case body // Matches JSON key
}
}
// JSON: {"source_link":"...", "content_name":"...", "body":"..."}
Excluding Properties
Omit properties from CodingKeys to exclude them from encoding/decoding:
struct NoteCollection: Codable {
let name: String
let notes: [Note]
var localDrafts: [Note] = [] // ✅ Must have default value
enum CodingKeys: CodingKey {
case name
case notes
// localDrafts omitted - not encoded/decoded
}
}
Rule: Excluded properties require default values or you must implement init(from:) manually.
Snake Case Conversion
For consistent snake_case → camelCase conversion:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// JSON: {"first_name":"Alice", "last_name":"Smith"}
// Decodes to: User(firstName: "Alice", lastName: "Smith")
Enum Associated Value Keys
Customize keys for enum associated values using {CaseName}CodingKeys:
enum Command: Codable {
case store(key: String, value: Int)
case delete(key: String)
enum StoreCodingKeys: String, CodingKey {
case key = "identifier" // Renames "key" to "identifier"
case value = "data" // Renames "value" to "data"
}
enum DeleteCodingKeys: String, CodingKey {
case key = "identifier"
}
}
// store case encodes as: {"store":{"identifier":"x","data":42}}
Pattern: {CaseName}CodingKeys with capitalized case name.
Part 3: Manual Implementation
For structural differences between JSON and Swift models, implement init(from:) and encode(to:).
Container Types
| Container | When to Use |
|---|---|
| Keyed | Dictionary-like data with string keys |
| Unkeyed | Array-like sequential data |
| Single-value | Wrapper types that encode as a single value |
| Nested | Hierarchical JSON structures |
Nested Containers Example
Flatten hierarchical JSON:
// JSON:
// {
// "latitude": 37.7749,
// "longitude": -122.4194,
// "additionalInfo": {
// "elevation": 52
// }
// }
struct Coordinate {
var latitude: Double
var longitude: Double
var elevation: Double // Nested in JSON, flat in Swift
enum CodingKeys: String, CodingKey {
case latitude, longitude, additionalInfo
}
enum AdditionalInfoKeys: String, CodingKey {
case elevation
}
}
extension Coordinate: Decodable {
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
latitude = try values.decode(Double.self, forKey: .latitude)
longitude = try values.decode(Double.self, forKey: .longitude)
let additionalInfo = try values.nestedContainer(
keyedBy: AdditionalInfoKeys.self,
forKey: .additionalInfo
)
elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
}
}
extension Coordinate: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(latitude, forKey: .latitude)
try container.encode(longitude, forKey: .longitude)
var additionalInfo = container.nestedContainer(
keyedBy: AdditionalInfoKeys.self,
forKey: .additionalInfo
)
try additionalInfo.encode(elevation, forKey: .elevation)
}
}
Bridge Types for Structural Mismatches
When JSON structure fundamentally differs from Swift model:
// JSON: {"USD": 1.0, "EUR": 0.85, "GBP": 0.73}
// Want: [ExchangeRate]
struct ExchangeRate {
let currency: String
let rate: Double
}
// Bridge type for decoding
private extension ExchangeRate {
struct List: Decodable {
let values: [ExchangeRate]
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let dictionary = try container.decode([String: Double].self)
values = dictionary.map { ExchangeRate(currency: $0, rate: $1) }
}
}
}
// Public interface
extension ExchangeRate {
static func decode(from data: Data) throws -> [ExchangeRate] {
let list = try JSONDecoder().decode(List.self, from: data)
return list.values
}
}
Part 4: Date Handling
Built-in Strategies
let decoder = JSONDecoder()
// 1. ISO 8601 (recommended)
decoder.dateDecodingStrategy = .iso8601
// Expects: "2024-02-15T17:00:00+01:00"
// 2. Unix timestamp (seconds)
decoder.dateDecodingStrategy = .secondsSince1970
// Expects: 1708012800
// 3. Unix timestamp (milliseconds)
decoder.dateDecodingStrategy = .millisecondsSince1970
// Expects: 1708012800000
// 4. Custom formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX") // ✅ Always set
formatter.timeZone = TimeZone(secondsFromGMT: 0) // ✅ Always set
decoder.dateDecodingStrategy = .formatted(formatter)
// 5. Custom closure
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = ISO8601DateFormatter().date(from: dateString) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode date string \(dateString)"
)
}
ISO 8601 Nuances
Default: 2024-02-15T17:00:00+01:00
Timezone required: Without timezone offset, decoding may fail across regions
// ❌ No timezone - parsing depends on device locale
"2024-02-15T17:00:00"
// ✅ With timezone - unambiguous
"2024-02-15T17:00:00+01:00"
Performance Consideration
Custom closures run for every date - optimize expensive operations:
// ❌ Creates new formatter for every date
decoder.dateDecodingStrategy = .custom { decoder in
let formatter = DateFormatter() // Expensive!
// ...
}
// ✅ Reuse formatter
let sharedFormatter = DateFormatter()
sharedFormatter.dateFormat = "yyyy-MM-dd"
decoder.dateDecodingStrategy = .custom { decoder in
// Use sharedFormatter
}
Part 5: Type Transformation
StringBacked Wrapper
Handle APIs that encode numbers as strings:
protocol StringRepresentable: CustomStringConvertible {
init?(_ string: String)
}
extension Int: StringRepresentable {}
extension Double: StringRepresentable {}
struct StringBacked<Value: StringRepresentable>: Codable {
var value: Value
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let value = Value(string) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot convert '\(string)' to \(Value.self)"
)
}
self.value = value
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value.description)
}
}
// Usage
struct Product: Codable {
let name: String
private let _price: StringBacked<Double>
var price: Double {
get { _price.value }
set { _price = StringBacked(value: newValue) }
}
enum CodingKeys: String, CodingKey {
case name
case _price = "price"
}
}
// JSON: {"name":"Widget","price":"19.99"}
// Decodes to: Product(name: "Widget", price: 19.99)
Type Coercion
For loosely typed APIs that may return different types:
struct FlexibleValue: Codable {
let stringValue: String
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
stringValue = string
} else if let int = try? container.decode(Int.self) {
stringValue = String(int)
} else if let double = try? container.decode(Double.self) {
stringValue = String(double)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode value to String, Int, or Double"
)
}
}
}
Warning: Avoid this pattern unless the API is truly unpredictable. Prefer strict types.
Part 6: Advanced Patterns
DecodableWithConfiguration (iOS 15+)
For types that need data unavailable in JSON:
struct User: Encodable, DecodableWithConfiguration {
let id: UUID
var name: String
var favorites: Favorites // Not in JSON, injected via configuration
enum CodingKeys: CodingKey {
case id, name
}
init(from decoder: Decoder, configuration: Favorites) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
favorites = configuration // Injected
}
}
// Usage (iOS 17+)
let favorites = try await fetchFavorites()
let user = try JSONDecoder().decode(
User.self,
from: data,
configuration: favorites
)
userInfo Workaround (iOS 15-16)
extension JSONDecoder {
private struct ConfigurationDecodingWrapper<T: DecodableWithConfiguration>: Decodable {
var wrapped: T
init(from decoder: Decoder) throws {
let config = decoder.userInfo[configurationUserInfoKey] as! T.DecodingConfiguration
wrapped = try T(from: decoder, configuration: config)
}
}
func decode<T: DecodableWithConfiguration>(
_ type: T.Type,
from data: Data,
configuration: T.DecodingConfiguration
) throws -> T {
let decoder = JSONDecoder()
decoder.userInfo[Self.configurationUserInfoKey] = configuration
let wrapper = try decoder.decode(ConfigurationDecodingWrapper<T>.self, from: data)
return wrapper.wrapped
}
}
private let configurationUserInfoKey = CodingUserInfoKey(rawValue: "configuration")!
Partial Decoding
Decode only the fields you need:
struct ArticlePreview: Decodable {
let id: UUID
let title: String
// Omit body, comments, etc.
}
// JSON has many more fields, but we only decode id and title
Part 7: Debugging
DecodingError Cases
do {
let user = try decoder.decode(User.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
print("Missing key '\(key)' at path: \(context.codingPath)")
} catch DecodingError.typeMismatch(let type, let context) {
print("Type mismatch for \(type) at path: \(context.codingPath)")
} catch DecodingError.valueNotFound(let type, let context) {
print("Value not found for \(type) at path: \(context.codingPath)")
} catch DecodingError.dataCorrupted(let context) {
print("Data corrupted at path: \(context.codingPath)")
} catch {
print("Other error: \(error)")
}
Debugging Techniques
1. Pretty-print JSON
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(user)
print(String(data: jsonData, encoding: .utf8)!)
2. Inspect coding path
// In custom init(from:)
print("Decoding at path: \(decoder.codingPath)")
3. Validate JSON structure
// Quick check: Can it decode as Any?
let json = try JSONSerialization.jsonObject(with: data)
print(json) // See actual structure
Anti-Patterns
| Anti-Pattern | Cost | Better Approach |
|---|---|---|
| Manual JSON string building | Injection vulnerabilities, escaping bugs, no type safety | Use JSONEncoder |
try? swallowing DecodingError |
Silent failures, debugging nightmares, data loss | Handle specific error cases |
| Optional properties to avoid decode errors | Runtime crashes, nil checks everywhere, masks structural issues | Fix JSON/model mismatch or use DecodableWithConfiguration |
| Duplicating partial models | 2-5 hours maintenance per change, sync issues, fragile | Use bridge types or configuration |
| Ignoring date timezone | Intermittent bugs across regions, data corruption | Always use ISO8601 with timezone or explicit UTC |
JSONSerialization for Codable types |
3x more boilerplate, manual type casting, error-prone | Use JSONDecoder/JSONEncoder |
| No locale on DateFormatter | Parsing fails in non-US locales | Set locale = Locale(identifier: "en_US_POSIX") |
Why try? is Dangerous
// ❌ Silent failure - production bug waiting to happen
let user = try? JSONDecoder().decode(User.self, from: data)
// If this fails, user is nil - why? No idea.
// ✅ Explicit error handling
do {
let user = try JSONDecoder().decode(User.self, from: data)
} catch {
logger.error("Failed to decode user: \(error)")
// Now you know WHY it failed
}
Pressure Scenarios
Scenario 1: "Just Use try? to Make It Compile"
Context: API integration deadline tomorrow, decoder failing on some edge case.
Pressure: "We can debug it later, just make it work now."
Why You'll Rationalize:
- "It's only failing on 1% of requests"
- "We can add logging later"
- "Customers won't notice"
What Actually Happens:
- Silent data loss for that 1%
- No logs, so you can't debug in production
- Customer complaints 3 months later
- You've forgotten the context by then
Discipline Response:
"Using
try?here means we'll lose data silently. Let me spend 5 minutes handling the specific error case. If it's truly rare, I'll log it so we can fix the root cause."
5-Minute Fix:
do {
return try decoder.decode(User.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
logger.error("Missing key '\(key)' in API response", metadata: [
"path": .string(context.codingPath.description),
"rawJSON": .string(String(data: data, encoding: .utf8) ?? "")
])
throw APIError.invalidResponse(reason: "Missing key: \(key)")
} catch {
logger.error("Failed to decode User", error: error)
throw APIError.decodingFailed(error)
}
Result: You discover the API sometimes omits the email field for deleted users. Fix: make email optional only for that case, not all users.
Scenario 2: "Dates Are Intermittent, Must Be Server Bug"
Context: Date parsing works in your timezone but fails for European QA team.
Pressure: "It works for me, QA must be doing something wrong."
Why You'll Rationalize:
- "My tests pass locally"
- "The server is probably sending bad data"
- "It's their device settings"
What Actually Happens:
- Server sends dates without timezone:
"2024-12-14T10:00:00" - Your device (PST) interprets as 10:00 PST
- QA device (CET) interprets as 10:00 CET
- Different absolute times, intermittent bugs
Discipline Response:
"Intermittent date failures are almost always timezone issues. Let me check if we're using ISO8601 with timezone offsets."
Check:
// ❌ Current (fails across timezones)
decoder.dateDecodingStrategy = .iso8601
// Server sends: "2024-12-14T10:00:00" (no timezone)
// PST device: Dec 14, 10:00 PST
// CET device: Dec 14, 10:00 CET
// Bug: Different times!
// ✅ Fix: Require server to send timezone
// "2024-12-14T10:00:00+00:00"
// OR: Explicitly parse as UTC
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.timeZone = TimeZone(secondsFromGMT: 0) // Force UTC
guard let date = formatter.date(from: dateString) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid ISO8601 date: \(dateString)"
)
}
return date
}
Result: Bug fixed, server adds timezone to API (or you parse explicitly as UTC). No more intermittent failures.
Scenario 3: "Just Make It Optional"
Context: New API field causes decoding to fail. Product manager wants a fix in 1 hour.
Pressure: "Can't you just make that field optional? We need this shipped."
Why You'll Rationalize:
- "It's faster than fixing the API"
- "We can make it non-optional later"
- "Users won't notice"
What Actually Happens:
- Field is actually required for the feature
- You add
user.email ?? ""everywhere - 3 months later: production crash because
emailwas nil - Now you can't remember why it was optional
Discipline Response:
"Making it optional masks the real problem. Let me check if the API is wrong or our model is wrong. This will take 10 minutes."
Investigation:
// Step 1: Print raw JSON
do {
let json = try JSONSerialization.jsonObject(with: data)
print(json)
} catch {
print("Invalid JSON: \(error)")
}
// Step 2: Check if key exists but value is null
// {"email": null} vs key missing entirely
// Step 3: Check API docs - is email actually required?
Common Outcomes:
- API is wrong: Field should be there → File bug, get hotfix
- Model is wrong: Field is optional in some flows → Use proper optionality with clear documentation
- Structural mismatch: Field is nested → Use nested container
Result: You discover email is nested in user.contact.email in the new API version. Fix with nested container, not optionality.
// ✅ Correct fix
struct User: Decodable {
let id: UUID
let email: String // Still required
enum CodingKeys: CodingKey {
case id, contact
}
enum ContactKeys: CodingKey {
case email
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
let contact = try container.nestedContainer(
keyedBy: ContactKeys.self,
forKey: .contact
)
email = try contact.decode(String.self, forKey: .email)
}
}
Related Skills
- swift-concurrency — Codable types crossing actor boundaries must be
Sendable - swiftdata —
@Modeltypes use Codable for CloudKit sync - networking —
Coderprotocol wraps Codable for Network.framework - app-intents-ref —
AppEnumparameters use Codable serialization
Key Takeaways
- Prefer automatic synthesis — Add
: Codablewhen structure matches JSON - Use CodingKeys for simple mismatches — Rename or exclude without manual code
- Manual implementation for structural differences — Nested containers, bridge types
- Always set locale and timezone —
DateFormatterrequiresen_US_POSIXand explicit timezone - Never swallow errors with try? — Handle
DecodingErrorcases explicitly - Codable + Sendable — Value types (structs/enums) are ideal for async networking
Core Principle: Codable is Swift's universal serialization protocol. Master it once, use it everywhere.