[Swift]Property Wrappersを使ってJSONの型が不確定な値をString型でデコードする方法


JSON形式のデータを取得しCodableに適合したstruct型に変換するといったケースにおいて、Stringとして扱いたいが場合によってInt型やDouble型になるかもしれない値が含まれる場合に有効な方法を紹介します。

動作環境

  • macOS Big Sur 11.3.1
  • XCode 12.5.1
  • Swift 5.4.2

通常のString型の値をデコードする場合

まずidentifiernameという値を持つItemという型を定義し、JSONからデコードする例を紹介します。

struct Item: Decodable {
    let identifier: String
    let name: String
}

let jsonString: String = """
    {
        "identifier": "123",
        "name": "Hello World"
    }
    """

let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let item = try! decoder.decode(Item.self, from: jsonData)
print("identifier:",item.identifier)
print("name:",item.name)
実行結果
identifier: 123
name: Hello World

identifierの値は123とあるものの、ダブルクォーテーションで囲われているため問題なくデコードできます。

値が数値の場合

identifierの値がダブルクォーテーションで囲われておらず、数値として扱われる場合はどうでしょうか?

let jsonString: String = """
    {
        "identifier": 123,
        "name": "Hello World"
    }
    """
実行結果
fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [Codingidentifiers(stringValue: "identifier", intValue: nil)], debugDescription: "Expected to decode String but found a number instead.", underlyingError: nil))

数値であるidentifierStringとしてデコードしようとしたため、エラーが発生してしまいました。

普通にinit(from decoder: Decoder)を実装する

Itemのデコード処理を以下のように書き換えます。

struct Item: Decodable {
    let identifier: String
    let name: String

    enum CodingKeys: String, CodingKey {
        case identifier
        case name
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        identifier = try (try? container.decode(String.self, forKey: .identifier))
            ?? (try "\(container.decode(Int.self, forKey: .identifier))")
        name = try container.decode(String.self, forKey: .name)
    }
}
実行結果
identifier: 123
name: Hello World

エラーが起きることなくデコードに成功しました。しかし、この方法ではItemにプロパティが増えた場合に特別な処理を必要としない場合でも逐一デコード処理を追加する必要があり、機能拡張が煩雑になります。

Property Wrapperを使う方法

同じデコード処理を記述するのに今度はSwift 5.1 から利用可能な Property Wrappersという機能を使って実装してみましょう。
まず以下のようなStringValueというPropertyWrapperを定義します。ついでにInt型以外にもDouble型やBool型にも対応しています。

@propertyWrapper
struct StringValue: Decodable {
    var wrappedValue: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        try wrappedValue = {
            return try (try? container.decode(String.self))
                ?? (try? "\(container.decode(Int.self))")
                ?? (try? "\(container.decode(Double.self))")
                ?? (try "\(container.decode(Bool.self))")
        }()
    }
}

そして、StringValueItemidentifierに適用します。

struct Item: Decodable {
    @StringValue
    private(set) var identifier: String
    let name: String
}
実行結果
identifier: 123
name: Hello World

この方法でもデコードに成功しました。こちらは最初にPropertyWrapperを用意する必要があるものの、一度定義してしまえば逐一デコード処理を書き直さずとも良くなります。

サンプル

最後にProperty Wrappersを使う場合のソースコード全文を掲載します。

import Foundation

@propertyWrapper
struct StringValue: Decodable {
    var wrappedValue: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        try wrappedValue = {
            return try (try? container.decode(String.self))
                ?? (try? "\(container.decode(Int.self))")
                ?? (try? "\(container.decode(Double.self))")
                ?? (try "\(container.decode(Bool.self))")
        }()
    }
}

struct Item: Decodable {
    @StringValue
    private(set) var identifier: String
    let name: String
}

let jsonString: String = """
    {
        "identifier": 123,
        "name": "Hello World",
    }
    """

let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let item = try! decoder.decode(Item.self, from: jsonData)
print("identifier:",item.identifier)
print("name:",item.name)