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 してくれるようになります。