AssociatedValue の有りと無しの case を複数持つ Enum を Decode する
AssociatedValue が無い case が1つ
enum Food { case hamburger(topping: String) case pizza }
上記のような Enum がありまして、これを Decodable に準拠すると
extension Food: Decodable { enum CodingKeys: String, CodingKey { case hamburger case pizza } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) { self = .hamburger(topping: value) } else if try container.decodeNil(forKey: .pizza) { self = .pizza } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } } }
こんな感じに書けます。 動作を確認するとそれぞれの case で decode できています。
let jsonString = """ { "hamburger": "cheeze" } """ let jsonData = jsonString.data(using: .utf8)! let food = try JSONDecoder().decode(Food.self, from: jsonData) // => hamburger(topping: "cheeze")
let jsonString = """ { "pizza": null } """ let jsonData = jsonString.data(using: .utf8)! let food = try JSONDecoder().decode(Food.self, from: jsonData) // => pizza
AssociatedValue が無い case が2つ
ただ、以下のように AssociatedValue の無い case がもう一つ追加されるとどうでしょうか。
enum Food { case hamburger(topping: String) case pizza case kebabu // New!!! }
decode メソッドは先ほどと同様に記述すると以下のようなコードをイメージするかと思います。
extension Food: Decodable { enum CodingKeys: String, CodingKey { case hamburger case pizza case kebabu } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) { self = .hamburger(topping: value) } else if try container.decodeNil(forKey: .pizza) { self = .pizza } else if try container.decodeNil(forKey: .kebabu) { // New!!! self = .kebabu } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } } }
しかし、これで kebabu
を decode しようとすると DecodingError.keyNotFound が throw されてしまいます。
let jsonString = """ { "kebabu": null } """ let jsonData = jsonString.data(using: .utf8)! let food = try JSONDecoder().decode(Food.self, from: jsonData) // ▿ keyNotFound : 2 elements // - .0 : CodingKeys(stringValue: "pizza", intValue: nil) // ▿ .1 : Context // - codingPath : 0 elements // - debugDescription : "No value associated with key CodingKeys(stringValue: \"pizza\", intValue: nil) (\"pizza\")." // - underlyingError : nil
これは、AssociatedValue の無い case を decode しているメソッド decodeNil(forKey:)
は、 Key が存在しないと DecodingError.keyNotFound を throw するため、 pizza
の時点で DecodingError.keyNotFound が throw されてしまっているからです。
解決案1
try? を使って DecodingError.keyNotFound が throw されないようにしてみましょう。
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) { self = .hamburger(topping: value) } else if (try? container.decodeNil(forKey: .pizza)) ?? false { // try? で Error を throw しないように self = .pizza } else if try container.decodeNil(forKey: .kebabu) { self = .kebabu } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } }
こうすることで、 kebabu
を decode することができます。
let jsonString = """ { "kebabu": null } """ let jsonData = jsonString.data(using: .utf8)! let food = try JSONDecoder().decode(Food.self, from: jsonData) // => kebabu
しかし、 if 分の順序を変えると壊れてしまったり、DecodingError.keyNotFound 以外の DecodingError も throw されなくなってしまうので、あまり好ましくなさそうです。
解決案2
throw された DecodingError.keyNotFound を catch するようにしてみます。
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) { self = .hamburger(topping: value) } else { do { if try container.decodeNil(forKey: .pizza) { self = .pizza } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } } catch DecodingError.keyNotFound { if try container.decodeNil(forKey: .kebabu) { self = .kebabu } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } } catch { throw error } } }
この方法であれば案1で述べたデメリットが解消されそうですが、記述量が多く、 case が増える度に do catch がネストしていってしまいます。
解決案3
これは案2の改良版で、DecodingError.keyNotFound を握りつぶすメソッドを生やします。
extension KeyedDecodingContainer where K : CodingKey { func decodeNilIfPresent(forKey key: K) throws -> Bool { do { return try decodeNil(forKey: key) } catch DecodingError.keyNotFound { return false } catch { throw error } } }
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) { self = .hamburger(topping: value) } else if try container.decodeNilIfPresent(forKey: .pizza) { self = .pizza } else if try container.decodeNilIfPresent(forKey: .kebabu) { self = .kebabu } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } }
これであれば案1、案2のデメリットがカバーされていそうです。
解決案 番外編
そもそも data に null を入れない。
例えば Int とかを入れておいて、 decodeNil(forKey:)
ではなく decodeIfPresent(_:,forKey:)
を使って値の有無を見るという感じです。
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) { self = .hamburger(topping: value) } else if try container.decodeIfPresent(Int.self, forKey: .pizza) != nil { self = .pizza } else if try container.decodeIfPresent(Int.self, forKey: .kebabu) != nil { self = .kebabu } else { let context = DecodingError.Context( codingPath: container.codingPath, debugDescription: "Unknown case" ) throw DecodingError.dataCorrupted(context) } }
let jsonString = """ { "kebabu": 1 } """ let jsonData = jsonString.data(using: .utf8)! let food = try JSONDecoder().decode(Food.self, from: jsonData) // => kebabu
この方法であれば、 Int 以外の期待しない型が入っている場合に DecodingError.typeMismatch を throw してくれるようになります。