axiom-codable について
JSON と PropertyList のエンコード、デコードを扱う Swift 6.x 開発者向けの Codable リファレンススキルです。自動合成、キーのカスタマイズ、手動変換、日付処理、デコードエラーの診断を解説します。
主な機能
- 01Codable の自動合成と列挙型
- 02CodingKeys によるキー設定
- 03コンテナとブリッジ型の変換
- 04日付デコード戦略
- 05デコードエラーの診断
活用シーン
- 01異なる名前の JSON キーを対応付け
- 02ネストまたは構造が異なるデータをデコード
- 03API の日付と型の揺れに対応
- 04Codable のデコード失敗を調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-codableインストール後、Claude Code で/axiom-codableを入力して読み込まれたか確認します。
詳細
Swift Codable のパターン
Swift 6.x における JSON および PropertyList のエンコード/デコードのための Codable プロトコル準拠に関する包括的なガイドです。
クイックリファレンス
判断ツリー:各アプローチを使用する場面
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
よくあるエラーのきっかけ
| エラー | 解決策 |
|---|---|
| "Type 'X' does not conform to protocol 'Decodable'" | すべての保存プロパティが Codable に準拠していることを確認します |
| "No value associated with key X" | CodingKeys が JSON のキーと一致していることを確認します |
| "Expected to decode X but found Y instead" | 型が一致していません。JSON の構造を確認するか、ブリッジ型を使用します |
| "keyNotFound" | JSON に想定されるキーがありません。プロパティをオプショナルにするか、デフォルト値を指定します |
| "Date parsing failed" | デコーダーで dateDecodingStrategy を設定します |
パート 1:自動合成
すべての保存プロパティが Codable に準拠している場合、Swift は Codable 準拠を自動的に合成します。
構造体の合成
// ✅ Automatic synthesis
struct User: Codable {
let id: UUID // Codable
var name: String // Codable
var membershipPoints: Int // Codable
}
// JSON: {"id":"...", "name":"Alice", "membershipPoints":100}
要件:
- すべての保存プロパティが Codable に準拠している必要があります
- プロパティには標準の Swift 型またはその他の Codable 型を使用します
- カスタム初期化ロジックは不要です
列挙型の合成パターン
パターン 1:Raw Value を持つ列挙型
enum Direction: String, Codable {
case north, south, east, west
}
// Encodes as: "north"
Raw Value 自体が JSON 表現になります。
パターン 2:Associated Value のない列挙型
enum Status: Codable {
case success
case failure
case pending
}
// Encodes as: {"success":{}}
各ケースは、ケース名をキー、空の辞書を値とするオブジェクトになります。
パターン 3:Associated Value を持つ列挙型
enum APIResult: Codable {
case success(data: String, count: Int)
case error(code: Int, message: String)
}
// success case encodes as:
// {"success":{"data":"example","count":5}}
注意点:ラベルのない Associated Value では、_0、_1 のキーが生成されます。
enum Command: Codable {
case store(String, Int) // ❌ Unlabeled
}
// Encodes as: {"store":{"_0":"value","_1":42}}
修正方法:予測可能な JSON にするため、Associated Value には常にラベルを付けます。
enum Command: Codable {
case store(key: String, value: Int) // ✅ Labeled
}
// Encodes as: {"store":{"key":"value","value":42}}
自動合成が機能しない場合
次の場合、自動合成は失敗します。
- 計算プロパティ - エンコードされるのは保存プロパティのみです
- Codable に準拠しないプロパティ - Codable 準拠のないカスタム型
- プロパティラッパー -
@Published、@State(Codable 型を使用する@AppStorageを除く) - クラスの継承 - サブクラスでは
init(from:)を手動で実装する必要があります
パート 2:CodingKeys のカスタマイズ
CodingKeys 列挙型を使うと、すべてを手動で実装することなく、エンコード/デコードをカスタマイズできます。
キーの名前変更
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":"..."}
プロパティの除外
プロパティを CodingKeys に含めないことで、エンコード/デコードから除外できます。
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
}
}
ルール:除外するプロパティにはデフォルト値が必要です。そうでない場合は、init(from:) を手動で実装する必要があります。
スネークケースへの変換
一貫した snake_case → camelCase の変換には、次を使用します。
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// JSON: {"first_name":"Alice", "last_name":"Smith"}
// Decodes to: User(firstName: "Alice", lastName: "Smith")
Enum の Associated Value キー
{CaseName}CodingKeys を使って、Enum の Associated Value のキーをカスタマイズします。
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}}
パターン:ケース名を大文字で始めた {CaseName}CodingKeys を使用します。
パート 3:手動実装
JSON と Swift のモデル間で構造に違いがある場合は、init(from:) と encode(to:) を実装します。
コンテナの型
| コンテナ | 使用する場面 |
|---|---|
| キー付き | 文字列キーを持つ辞書のようなデータ |
| 非キー付き | 順序に従う配列のようなデータ |
| 単一値 | 単一の値としてエンコードされるラッパー型 |
| ネスト | 階層構造の JSON |
ネストしたコンテナの例
階層構造の 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)
}
}
構造の不一致に対応するブリッジ型
JSON の構造が 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
}
}
パート 4:日付の処理
組み込みのストラテジー
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 の注意点
デフォルト:2024-02-15T17:00:00+01:00
タイムゾーンが必須:タイムゾーンオフセットがない場合、地域によってはデコードに失敗することがあります
// ❌ No timezone - parsing depends on device locale
"2024-02-15T17:00:00"
// ✅ With timezone - unambiguous
"2024-02-15T17:00:00+01:00"
パフォーマンスに関する考慮事項
カスタムクロージャーは日付ごとに実行されます。負荷の高い処理を最適化してください。
// ❌ 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
}
パート 5:型変換
StringBacked ラッパー
数値を文字列としてエンコードする API に対応します。
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)
型の強制変換
異なる型を返すことがある、型付けが緩い API には次を使用します。
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"
)
}
}
}
警告:API の戻り値が本当に予測不能な場合を除き、このパターンは避けてください。厳密な型を優先します。
パート 6:高度なパターン
DecodableWithConfiguration(iOS 15 以降)
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 を使った回避策(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")!
部分デコード
必要なフィールドだけをデコードします:
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
パート 7: デバッグ
DecodingError のケース
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)")
}
デバッグ手法
1. JSON を整形して出力する
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(user)
print(String(data: jsonData, encoding: .utf8)!)
2. コーディングパスを確認する
// In custom init(from:)
print("Decoding at path: \(decoder.codingPath)")
3. JSON 構造を検証する
// Quick check: Can it decode as Any?
let json = try JSONSerialization.jsonObject(with: data)
print(json) // See actual structure
アンチパターン
| アンチパターン | コスト | より良いアプローチ |
|---|---|---|
| 手動での JSON 文字列構築 | インジェクション脆弱性、エスケープのバグ、型安全性の欠如 | JSONEncoder を使用する |
try? で DecodingError を握りつぶす | サイレント障害、デバッグの悪夢、データ損失 | 特定のエラーケースを処理する |
| デコードエラーを避けるためのオプショナルプロパティ | 実行時クラッシュ、至る所の nil チェック、構造的な問題の隠蔽 | JSON とモデルの不一致を修正するか、DecodableWithConfiguration を使用する |
| 部分モデルの重複 | 変更ごとに 2〜5 時間のメンテナンス、同期の問題、脆弱性 | ブリッジ型または構成を使用する |
| 日付のタイムゾーンの無視 | 地域をまたぐ断続的なバグ、データ破損 | 常にタイムゾーン付きの ISO8601 または明示的な UTC を使用する |
Codable 型に対する JSONSerialization | 3 倍のボイラープレート、手動の型キャスト、エラーが発生しやすい | JSONDecoder/JSONEncoder を使用する |
| DateFormatter にロケールがない | 米国以外のロケールでパースが失敗する | locale = Locale(identifier: "en_US_POSIX") を設定する |
try? が危険な理由
// ❌ 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
}
プレッシャーシナリオ
シナリオ 1: 「コンパイルを通すために try? を使えばいい」
状況: API 統合の締め切りが明日、デコーダーが一部のエッジケースで失敗している。
プレッシャー: 「後でデバッグすればいい、今はとにかく動かしてくれ。」
あなたが正当化してしまう理由:
- 「リクエストの 1% でしか失敗していない」
- 「後でログを追加すればいい」
- 「顧客は気づかないだろう」
実際に起こること:
- その 1% でサイレントなデータ損失が発生する
- ログがないため、本番環境でデバッグできない
- 3 か月後に顧客からの苦情が来る
- その頃にはコンテキストを忘れている
規律ある対応:
「ここで
try?を使うと、データをサイレントに失うことになります。特定のエラーケースの処理に 5 分だけかけさせてください。本当に稀なケースなら、根本原因を修正できるようログを出力します。」
5 分でできる修正:
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)
}
結果: API が削除済みユーザーの email フィールドを省略することがあると判明。修正: email をすべてのユーザーではなく、そのケースに限ってオプショナルにする。
シナリオ 2: 「日付が断続的に失敗する、サーバーのバグに違いない」
状況: 日付のパースはあなたのタイムゾーンでは動作するが、ヨーロッパの QA チームでは失敗する。
プレッシャー: 「自分では動いている、QA が何か間違っているに違いない。」
あなたが正当化してしまう理由:
- 「ローカルではテストが通っている」
- 「サーバーが不正なデータを送っているのだろう」
- 「彼らのデバイス設定の問題だ」
実際に起こること:
- サーバーがタイムゾーンなしで日付を送信する:
"2024-12-14T10:00:00" - あなたのデバイス (PST) は 10:00 PST と解釈する
- QA のデバイス (CET) は 10:00 CET と解釈する
- 異なる絶対時刻になり、断続的なバグが発生する
規律ある対応:
「断続的な日付の失敗は、ほとんどの場合タイムゾーンの問題です。タイムゾーンオフセット付きの ISO8601 を使用しているか確認させてください。」
確認:
// ❌ 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
}
結果: バグが修正され、サーバーが API にタイムゾーンを追加する (または明示的に UTC としてパースする)。断続的な失敗はなくなる。
シナリオ 3:「とりあえずオプショナルにする」
状況:新しい API フィールドが原因でデコードに失敗しています。プロダクトマネージャーは 1 時間以内の修正を求めています。
プレッシャー:「そのフィールドをオプショナルにするだけではだめですか?これをリリースする必要があります。」
合理化してしまう理由:
- 「API を修正するより早い」
- 「あとで非オプショナルに戻せばいい」
- 「ユーザーは気づかない」
実際に起こること:
- そのフィールドは機能に実際に必要です
- あらゆる場所に
user.email ?? ""を追加します - 3 か月後:
emailが nil のため、本番環境でクラッシュします - なぜオプショナルにしたのか、もう思い出せません
規律ある対応:
「オプショナルにすると、本当の問題が隠れてしまいます。API とモデルのどちらが間違っているのか確認させてください。10 分ほどかかります。」
調査:
// 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?
よくある結果:
- API が間違っている:フィールドがあるべきです → バグを報告し、緊急修正を依頼します
- モデルが間違っている:一部のフローではフィールドがオプショナルです → 明確なドキュメントを添えて、適切にオプショナルとして扱います
- 構造が一致していない:フィールドがネストされています → ネストされたコンテナーを使います
結果:新しい API バージョンでは、email が user.contact.email の中にあることがわかります。オプショナルにするのではなく、ネストされたコンテナーで修正します。
// ✅ 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)
}
}
関連スキル
- swift-concurrency — アクター境界を越える Codable 型は
Sendableである必要があります - swiftdata —
@Model型は CloudKit 同期に Codable を使用します - networking —
Coderプロトコルは Network.framework 向けに Codable をラップします - app-intents-ref —
AppEnumパラメーターは Codable シリアライズを使用します
重要なポイント
- 自動合成を優先する — 構造が JSON と一致する場合は
: Codableを追加します - 単純な不一致には CodingKeys を使う — 手動でコードを書かずに名前を変更したり、除外したりできます
- 構造の違いには手動実装を使う — ネストされたコンテナーやブリッジ型に対応します
- ロケールとタイムゾーンを必ず設定する —
DateFormatterにはen_US_POSIXと明示的なタイムゾーンが必要です - try? でエラーを握りつぶさない —
DecodingErrorのケースを明示的に処理します - Codable + Sendable — 値型(struct/enum)は非同期ネットワーキングに最適です
基本原則:Codable は Swift の汎用シリアライズプロトコルです。一度習得すれば、どこでも活用できます。