6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Codableで複数の型を取りうる配列を持つJSONに対応する

Last updated at Posted at 2022-07-02

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

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

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

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


やりたいこと

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

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

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の回答を見つけました。


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


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

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
6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?