Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
7
.claude/skills/axiom-codable/.openskills.json
Normal file
7
.claude/skills/axiom-codable/.openskills.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-codable",
|
||||
"installedAt": "2026-04-12T08:06:02.173Z"
|
||||
}
|
||||
832
.claude/skills/axiom-codable/SKILL.md
Normal file
832
.claude/skills/axiom-codable/SKILL.md
Normal file
@@ -0,0 +1,832 @@
|
||||
---
|
||||
name: axiom-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
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
```swift
|
||||
// ✅ 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
|
||||
|
||||
```swift
|
||||
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
|
||||
|
||||
```swift
|
||||
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
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
enum Command: Codable {
|
||||
case store(String, Int) // ❌ Unlabeled
|
||||
}
|
||||
|
||||
// Encodes as: {"store":{"_0":"value","_1":42}}
|
||||
```
|
||||
|
||||
**Fix**: Always label associated values for predictable JSON:
|
||||
|
||||
```swift
|
||||
enum Command: Codable {
|
||||
case store(key: String, value: Int) // ✅ Labeled
|
||||
}
|
||||
|
||||
// Encodes as: {"store":{"key":"value","value":42}}
|
||||
```
|
||||
|
||||
### When Synthesis Breaks
|
||||
|
||||
Automatic synthesis fails when:
|
||||
1. **Computed properties** - Only stored properties are encoded
|
||||
2. **Non-Codable properties** - Custom types without Codable conformance
|
||||
3. **Property wrappers** - `@Published`, `@State` (except `@AppStorage` with Codable types)
|
||||
4. **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
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
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`:
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
// 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:
|
||||
|
||||
```swift
|
||||
// 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
|
||||
|
||||
```swift
|
||||
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
|
||||
|
||||
```swift
|
||||
// ❌ 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:
|
||||
|
||||
```swift
|
||||
// ❌ 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:
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
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)
|
||||
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
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
|
||||
|
||||
```swift
|
||||
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**
|
||||
|
||||
```swift
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let jsonData = try encoder.encode(user)
|
||||
print(String(data: jsonData, encoding: .utf8)!)
|
||||
```
|
||||
|
||||
**2. Inspect coding path**
|
||||
|
||||
```swift
|
||||
// In custom init(from:)
|
||||
print("Decoding at path: \(decoder.codingPath)")
|
||||
```
|
||||
|
||||
**3. Validate JSON structure**
|
||||
|
||||
```swift
|
||||
// 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
|
||||
|
||||
```swift
|
||||
// ❌ 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**:
|
||||
|
||||
```swift
|
||||
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**:
|
||||
|
||||
```swift
|
||||
// ❌ 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 `email` was 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**:
|
||||
|
||||
```swift
|
||||
// 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**:
|
||||
1. **API is wrong**: Field should be there → File bug, get hotfix
|
||||
2. **Model is wrong**: Field is optional in some flows → Use proper optionality with clear documentation
|
||||
3. **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.
|
||||
|
||||
```swift
|
||||
// ✅ 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** — `@Model` types use Codable for CloudKit sync
|
||||
- **networking** — `Coder` protocol wraps Codable for Network.framework
|
||||
- **app-intents-ref** — `AppEnum` parameters use Codable serialization
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Prefer automatic synthesis** — Add `: Codable` when structure matches JSON
|
||||
2. **Use CodingKeys for simple mismatches** — Rename or exclude without manual code
|
||||
3. **Manual implementation for structural differences** — Nested containers, bridge types
|
||||
4. **Always set locale and timezone** — `DateFormatter` requires `en_US_POSIX` and explicit timezone
|
||||
5. **Never swallow errors with try?** — Handle `DecodingError` cases explicitly
|
||||
6. **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.
|
||||
3
.claude/skills/axiom-codable/agents/openai.yaml
Normal file
3
.claude/skills/axiom-codable/agents/openai.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Codable"
|
||||
short_description: "Working with Codable protocol, JSON encoding/decoding, CodingKeys customization, enum serialization, date strategies,..."
|
||||
Reference in New Issue
Block a user