BambooHero

iOSアプリ開発と株式投資をメインに色々書きます

Codableでプロパティの一部が任意の型になる場合に対応する

f:id:bamboohero:20210525012058p:plain とあるAPIのレスポンスをデコードする処理を書いていて、以下のように配列の中の型が複数あるケースに対応する必要がありました。

{
    "products": [
        {
            "id": 1,
            "flag": true,
            "foo": "foofoo",
            "bar": "barbar"
        },
        {
           "id": 2,
           "flag": false,
           "baz": "bazbaz",
           "qux": "quxqux"
        }
    ],
    "next": "xxxxxxx"
}


idflagのように、一部共通のプロパティも持っていて、これらを抽象的な型でまとめて表現したいというモチベーションもありました。

参考になる(というかほぼそのまま使える)コードをStackOverflowで見つけてなんとか実現できたので、その方法をご紹介したいと思います。



やりたいこと

先ほどのJSONを以下のような型にデコードしたいです。

protocol Product: Decodable {
    var id: Int { get }
    var type: ProductType { get }
}

enum ProductType: String, Decodable {
    case foo = "FOO"
    case baz = "BAZ"
}

struct ProductFoo: Product {
    let id: Int
    let type: ProductType
    let foo: String
    let bar: String
}

struct ProductBaz: Product {
    let id: Int
    let type: ProductType
    let baz: String
    let qux: String
}


標準のDecodableだけだとコンパイルエラーになる

上記の宣言をしても、このままだと以下のエラーが出てコンパイルできません。ProductがProtocolとして宣言されていて、具体的な型が確定できないからですね。

Type 'Response' does not conform to protocol 'Decodable'

自動でDecodableに準拠できないのであれば、自前でinit(from:)を実装して準拠させてみましょう。配列の中が任意のJSONになるのであれば、一旦[Any]としてデコードして、あとでProductFooかProductBazに変換してあげれば良いと考えました。

struct Response: Decodable {
    let products: [Product]
    let next: String?

    enum CodingKeys: String, CodingKey {
        case products
        case next
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.next = try container.decode(String?.self, forKey: .next)

        let anyProducts = try container.decode([Any].self, forKey: .products)  // コンパイルエラー
        ...
    }
}

しかし、これでもコンパイルエラーになります。

No exact matches in call to instance method 'decode'

containerはKeyedDecodingContainerという標準ライブラリが提供する構造体なのですが、こいつに[Any]にデコードするメソッドが定義されていないため、コンパイルエラーになってしまいます。


[Any]でデコードできるようにする

どうしたものか困っていたところ、ドンピシャなStackOverflowの回答を見つけました。

stackoverflow.com

この回答にリンクされているGistのコードを導入すると、[Any]もしくは[String: Any]でデコードできるようになります。

https://gist.github.com/loudmouth/332e8d89d8de2c1eaf81875cfcd22e24

若干タイポがあったりするので、ちょっと手直ししたコードがこちらです。

struct JSONCodingKeys: CodingKey {
    var stringValue: String

    init?(stringValue: String) {
        self.stringValue = stringValue
    }

    var intValue: Int?

    init?(intValue: Int) {
        self.init(stringValue: "\(intValue)")
        self.intValue = intValue
    }
}

extension KeyedDecodingContainer {
    func decode(_ type: Array<Any>.Type, forKey key: K) throws -> Array<Any> {
        var container = try nestedUnkeyedContainer(forKey: key)
        return try container.decode(type)
    }

    func decodeIfPresent(_ type: Array<Any>.Type, forKey key: K) throws -> Array<Any>? {
        guard contains(key) else {
            return nil
        }
        return try decode(type, forKey: key)
    }

    func decode(_ type: Dictionary<String, Any>.Type, forKey key: K) throws -> Dictionary<String, Any> {
        let container = try nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key)
        return try container.decode(type)
    }

    func decodeIfPresent(_ type: Dictionary<String, Any>.Type, forKey key: K) throws -> Dictionary<String, Any>? {
        guard contains(key) else {
            return nil
        }
        return try decode(type, forKey: key)
    }

    func decode(_ type: Dictionary<String, Any>.Type) throws -> Dictionary<String, Any> {
        var dictionary = Dictionary<String, Any>()

        for key in allKeys {
            if let boolValue = try? decode(Bool.self, forKey: key) {
                dictionary[key.stringValue] = boolValue
            } else if let stringValue = try? decode(String.self, forKey: key) {
                dictionary[key.stringValue] = stringValue
            } else if let intValue = try? decode(Int.self, forKey: key) {
                dictionary[key.stringValue] = intValue
            } else if let doubleValue = try? decode(Double.self, forKey: key) {
                dictionary[key.stringValue] = doubleValue
            } else if let nestedDictionary = try? decode(Dictionary<String, Any>.self, forKey: key) {
                dictionary[key.stringValue] = nestedDictionary
            } else if let nestedArray = try? decode(Array<Any>.self, forKey: key) {
                dictionary[key.stringValue] = nestedArray
            }
        }
        return dictionary
    }
}

extension UnkeyedDecodingContainer {
    mutating func decode(_ type: Array<Any>.Type) throws -> Array<Any> {
        var array: [Any] = []
        while isAtEnd == false {
            if let value = try? decode(Bool.self) {
                array.append(value)
            } else if let value = try? decode(Double.self) {
                array.append(value)
            } else if let value = try? decode(String.self) {
                array.append(value)
            } else if let nestedDictionary = try? decode(Dictionary<String, Any>.self) {
                array.append(nestedDictionary)
            } else if let nestedArray = try? decode(Array<Any>.self) {
                array.append(nestedArray)
            }
        }
        return array
    }

    mutating func decode(_ type: Dictionary<String, Any>.Type) throws -> Dictionary<String, Any> {
        let nestedContainer = try nestedContainer(keyedBy: JSONCodingKeys.self)
        return try nestedContainer.decode(type)
    }
}

これで[String: Any]もしくは[Any]でデコードできるようになります。


[Any]でデコードしたあと任意の型に変換する

init(from:)の実装の完全版はこちらです。

struct Response: Decodable {
    let products: [Product]
    let next: String?

    enum CodingKeys: String, CodingKey {
        case products
        case next
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.next = try container.decode(String?.self, forKey: .next)

        // [1]
        let anyProducts = try container.decode([Any].self, forKey: .products)

        self.products = anyProducts.compactMap {
            // [2]
            guard let data = try? JSONSerialization.data(withJSONObject: $0, options: []) else { return nil }

            // [3]
            if let product = try? JSONDecoder().decode(ProductFoo.self, from: data) {
                return product
            } else if let product = try? JSONDecoder().decode(ProductBaz.self, from: data) {
                return product
            } else {
                return nil
            }
        }
    }
}

[1]
productsの配列は一旦[Any]としてデコードします。

[2]
JSONSerializationでData型に変換します。

[3]
JSONDecoderで具体的な型に変換します。try?で変換処理を実行し、型が合わなければelse ifでつなげて別の型で変換を実行します。


任意の型にデコードできるようになった

最初のJSONはこんな感じで変換できるようになりました。products内はProductFooもしくはProductBazという型に変換され、またProduct型として共通の振る舞いを持つこともできています。

let json = """
{
    "products": [
        {
            "id": 1,
            "type": "FOO",
            "foo": "foofoo",
            "bar": "barbar"
        },
        {
            "id": 2,
            "type": "BAZ",
            "baz": "bazbaz",
            "qux": "quxqux"
        }
    ],
    "next": "xxxxxxx"
}
"""

let decoder = JSONDecoder()
let response = try! decoder.decode(Response.self, from: json.data(using: .utf8)!)
let foo = response.products[0] as! ProductFoo
let baz = response.products[1] as! ProductBaz

print(foo.id)  // 1
print(foo.type)  // foo
print(foo.foo)  // foofoo
print(foo.bar)  // barbar
print(baz.id)  // 2
print(baz.type)  // baz
print(baz.baz)  // bazbaz
print(baz.qux)  // quxqux

response.products.forEach { print("id: \($0.id), type: \($0.type)") }
// id: 1, type: foo
// id: 2, type: baz