えいむーさんは明日も頑張るよ

未知の値に対応したEnumを考えるとややこしい問題

価格

Codable + Enum

Swift の Codable では Enum の値も直接変換することができます。

enum PaymentType: Int, CaseIterable, Codable {
    case クレジット = 0
    case 現金 = 1
}

struct Transaction: Codable {
    let payment: PaymentType
    let subtotal: Int
}

のような感じで定義しておけば、

{
  "payment": 1,
  "subtotal": 1500
}

の JSON を特別なコードを必要とせずに受け取ることができます。

文字列+整数

で、こんな感じで簡単に受けられればそれでいいのですが、世の中には世にも奇天烈なレスポンスを返す API が多数存在します。

多分最も多いのが、Enum で整数型しか返さないのにわざわざ文字列で返してくるタイプです。

{
  "payment": "1",
  "subtotal": 1500
}

これを受けるには、

enum PaymentType: String, CaseIterable, Codable {
    case クレジット = "0"
    case 現金 = "1"
}

とすればいいのですが、整数型で返ってくるとわかっているものを文字列で受け取るのは違和感があります。

要件 1

実質整数型が返ってくるものに対しては文字列型で返ってきたとしても整数型として受け取る。

更に、これだけでなく、

要件 2

整数型として返ってきた場合はそのまま整数型として受け取る。

この二つの要件をまとめると、

[
  {
    "payment": "1",
    "subtotal": 1500
  },
  {
    "payment": 1,
    "subtotal": 1500
  }
]

のようなデータが来ても正しく受け取りたいということになります。

Null

次にnullを許容する Enum を考えます。

Swift の Codable では Value にnilが入るパターンは次の二つが考えられます。

  1. キーが存在しない場合
  2. 値がnullの場合

これらはそれぞれ、以下の JSON に対応します。

[
  {
    "subtotal": 1500
  },
  {
    "payment": null,
    "subtotal": 1500
  }
]

よって、

要件 3

キーが存在しない場合はnilを割り当てる。

要件 4

値がnullの場合はnilを割り当てる。

の二つの要件が加わります。

ちなみに、nilを受け取る場合にはプロパティ自体をオプショナルにする必要があります。

struct Transaction: Codable {
    let payment: PaymentType? // オプショナルに変更
    let subtotal: Int
}

よってTransactionの定義を上のようにオプショナルに変更する必要があります。

Encodable

次に Encodable について考えます。

Swift はnilが入ったプロパティを Encode するとキー自体が消えてしまうことが知られています。これはつまり、

struct Transaction: Codable {
    let payment: PaymentType? // オプショナルに変更
    let subtotal: Int
}

let transaction = Transaction(payment: nil, subtotal: 1000)

を Encode すると、

{
  "subtotal": 1000
}

になってしまうことを意味します。API によってはキーが存在しないと正しいデータとして受け取ってくれない場合があるので、

{
  "payment": null,
  "subtotal": 1000
}

と出力されてほしいわけです。

要件 5

nilが入ったプロパティはエンコードするとnullが割り当てられる。

未知の値

ここまでの話は Enum の値が完全に一対一対応している場合の話でしたが、API のアップデートで急に知らないレスポンスが返ってくることがあります。

例えば、決済方法として交通系 IC が追加されたとしましょう。

{
  "payment": 2, // 交通系ICを意味するEnum
  "subtotal": 1000
}

すると、交通系 IC には 2 という値が割り当てられることになります。

これをそのままPaymentTypeに変換しようとするとCannot initialize PaymentType from invalid Int value 2というエラーが発生し、2 という値に対応する Enum が PaymentType にないとしてデコードすることができません。

要件 6

未知の値に対してはunknownを割り当てる。

コードを書いてみよう

では、これらの要件を満たすコードを考えましょう。

条件としては、最終的には整数型として受け取りたいので、Enum の定義は、

enum PaymentType: Int, CaseIterable, Codable {
    case クレジット = 0
    case 現金 = 1
}

となります。

定義について

実はこの定義のままでは全ての要件を満たせないが、最初はこれで定義しておきます。

テンプレート

Playground で以下のようなコードを書けば実際にどのように動作するかが簡単にチェックできます。

import Foundation

let json = """
[
  {
    "payment": 0,
    "subtotal": 1500
  },
  {
    "payment": 1,
    "subtotal": 1000
  }
]
"""

enum PaymentType: Int, CaseIterable, Codable {
    case クレジット = 0
    case 現金 = 1
}

struct Transaction: Codable {
    let payment: PaymentType
    let subtotal: Int
}

let decoder: JSONDecoder = JSONDecoder()

do {
    let data = json.data(using: .utf8)!
    let response = try decoder.decode([Transaction].self, from: data)
    print(response)
} catch {
    print(error)
}

これを実行すると、正しく

[
    Page_Contents.Transaction(payment: Page_Contents.PaymentType.クレジット, subtotal: 1500),
    Page_Contents.Transaction(payment: Page_Contents.PaymentType.現金, subtotal: 1000)
]

という結果が得られると思います。では一方を文字列にしてしまうとどうなるでしょうか?

let json = """
[
  {
    "payment": "0",
    "subtotal": 1500
  },
  {
    "payment": 1,
    "subtotal": 1000
  }
]
"""

つまり、上のように JSON の中身を書き換えるということになります。

typeMismatch(Swift.Int, Swift.DecodingError.Context(codingPath: [_JSONKey(stringValue: "Index 0", intValue: 0), CodingKeys(stringValue: "payment", intValue: nil)], debugDescription: "Expected to decode Int but found a string/data instead.", underlyingError: nil))

すると上のようなエラーが発生して、デコードすることができません。エラーの内容は Int 型だと思っていたら String 型が入っていたぞ、というようなものになります。

さて、これをどうやって対応するかを考えます。

独自イニシャライザの実装

一つ目の対応方法は、独自イニシャライザの実装です。

extension PaymentType {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        /// Int -> PaymentType
        if let intValue = try? container.decode(Int.self) {
            if let rawValue = PaymentType(rawValue: intValue) {
                self = rawValue
                return
            }
            /// Int -> PaymentTypeに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot initialize PaymentType from invalid Int value \(intValue)"))
        }

        /// String -> Int -> PaymentType
        let stringValue = try container.decode(String.self)
        if let intValue = Int(stringValue) {
            if let rawValue = PaymentType(rawValue: intValue) {
                self = rawValue
                return
            }
            /// Int -> PaymentTypeに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot initialize PaymentType from invalid Int value \(intValue)"))
        }
        /// String -> Intに失敗
        throw DecodingError.typeMismatch(PaymentType.self, DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot convert to Int from String value \(stringValue)"))
    }
}

これに対応するには例えば上のようなコードを書きます。

こうすれば受け取った方が Int に変換可能ならそのまま PaymentType への変換を行い、String なら Int に一度変換してから PaymentType に変換を行います。

[
    Page_Contents.Transaction(payment: Page_Contents.PaymentType.クレジット, subtotal: 1500),
    Page_Contents.Transaction(payment: Page_Contents.PaymentType.現金, subtotal: 1000)
]

なのでこのコードは正しく動作します。

ちなみに、これだけだと PaymentType にしか適応できませんが、次のようにジェネリクスを使って改良することで RawValue が Int のものであればすべての Enum に対応できます。

extension RawRepresentable where RawValue == Int, Self: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        /// Int -> PaymentType
        if let intValue = try? container.decode(Int.self) {
            if let rawValue = Self(rawValue: intValue) {
                self = rawValue
                return
            }
            /// Int -> RawRepresentableに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot initialize RawRepresentable from invalid Int value \(intValue)"))
        }

        /// String -> Int -> RawRepresentable
        let stringValue = try container.decode(String.self)
        if let intValue = Int(stringValue) {
            if let rawValue = Self(rawValue: intValue) {
                self = rawValue
                return
            }
            /// Int -> RawRepresentableに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot initialize RawRepresentable from invalid Int value \(intValue)"))
        }
        /// String -> Intに失敗
        throw DecodingError.typeMismatch(Self.self, DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot convert to Int from String value \(stringValue)"))
    }
}

これで要件 1 と要件 2 は満たすことができました。

KeyedDecodingContainer

独自イニシャライザ以外の方法として、decodeの関数を自作してしまう方法があります。

extension KeyedDecodingContainer {
    func decode(_ type: PaymentType.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> PaymentType {
        if let intValue = try? decode(Int.self, forKey: key) {
            if let rawValue = PaymentType(rawValue: intValue) {
                return rawValue
            }
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: codingPath, debugDescription: "Cannot initialize PaymentType from invalid Int value \(intValue)"))
        }

        let stringValue = try decode(String.self, forKey: key)
        if let intValue = Int(stringValue) {
            if let rawValue = PaymentType(rawValue: intValue) {
                return rawValue
            }
            /// Int -> PaymentTypeに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: codingPath, debugDescription: "Cannot initialize PaymentType from invalid Int value \(intValue)"))
        }
        /// String -> Intに失敗
        throw DecodingError.typeMismatch(PaymentType.self, DecodingError.Context.init(codingPath: codingPath, debugDescription: "Cannot convert to Int from String value \(stringValue)"))
    }
}

やっていることは同じなので好みの方で良いと思います。こちらの手法の良いところはオプショナルにも簡単に対応できて、

extension KeyedDecodingContainer {
    func decodeIfPresent(_ type: PaymentType.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> PaymentType? {
        // 処理
    }
}

を定義すればオプショナルの場合でも正しく変換できます。

オプショナルに対応

次はオプショナルに対応させましょう。

要件 1 と 2 を満たしつつ、3 と 4 を満たすには以下の JSON を正しくデコードできれば良いことになります。

let json = """
[
  {
    "payment": 0,
    "subtotal": 1500
  },
  {
    "payment": 1,
    "subtotal": 1000
  },
  {
    "payment": "1",
    "subtotal": 1000
  },
  {
    "payment": null,
    "subtotal": 1000
  },
  {
    "subtotal": 1000
  }
]
"""

先程のコードに対してこの JSON を与えるとそれぞれ、

  1. nullの場合
    • Expected String but found null value instead.
  2. キーが存在しない場合
    • No value associated with key CodingKeys(stringValue: \"payment\", intValue: nil) (\"payment\").

というエラーが発生します。

`null`の場合のエラー

Expected Int but found null value instead.ではなく、何故Stringなのだろうかと気になるかもしれないが、Intへの変換はtry?でエラーがnilに置き換えられているため発生しない。

これに対応するのは簡単で、単にTransaction構造体のプロパティをオプショナルに変更するだけで良い。

struct Transaction: Codable {
    let payment: PaymentType? // オプショナルに変更
    let subtotal: Int
}

こうしてやればエラーなくデータを変換できて、

[
    Page_Contents.Transaction(payment: Optional(Page_Contents.PaymentType.クレジット), subtotal: 1500),
    Page_Contents.Transaction(payment: Optional(Page_Contents.PaymentType.現金), subtotal: 1000),
    Page_Contents.Transaction(payment: Optional(Page_Contents.PaymentType.現金), subtotal: 1000),
    Page_Contents.Transaction(payment: nil, subtotal: 1000),
    Page_Contents.Transaction(payment: nil, subtotal: 1000)
]

というふうにデコードすることができました。

エンコードに対応

エンコードしたデータを見るには以下のコードが便利です。

let encoder: JSONEncoder = {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
    return encoder
}()

/// String -> Data
let data = json.data(using: .utf8)!
/// Data -> [Transaction] Decode
let response = try decoder.decode([Transaction].self, from: data)
/// [Transaction] -> Data -> String Encode
print(String(data: try encoder.encode(response), encoding: .utf8)!)

すると結果として、

[
  {
    "payment": 0,
    "subtotal": 1500
  },
  {
    "payment": 1,
    "subtotal": 1000
  },
  {
    "payment": 1,
    "subtotal": 1000
  },
  {
    "subtotal": 1000
  },
  {
    "subtotal": 1000
  }
]

が得られます。やはり普通に実装しただけではnullを値に持つキーが消えてしまいます。

KeyedEncodingContainer

この Extension はencodeが実行されたときに呼ばれます。

プロパティがオプショナルなら常にencodeIfPresentが呼ばれ、そうでない場合はencodeが呼ばれます。

extension KeyedEncodingContainer {
    mutating func encodeIfPresent(_ value: PaymentType?, forKey key: KeyedEncodingContainer<K>.Key) throws {
        switch value {
        case .some(let value):
            try encode(value, forKey: key)
        case .none:
            try encodeNil(forKey: key)
        }
    }
}

このように書けば値がOptional<PaymentType>のときにこのencodeが実行されます。

本来であればnilが入った場合にはencode自体がnilを返してしまうのですが、こうすればキーが保持されたまま値としてnilが返るので JSON になったときにnullが入ります。

[
  {
    "payment": 0,
    "subtotal": 1500
  },
  {
    "payment": 1,
    "subtotal": 1000
  },
  {
    "payment": null,
    "subtotal": 1000
  }
]

無事にnullが出力でき、要件 5 を満たすことができました。

ここまでのまとめ

要件 5 までを満たすコードの全文はこちら。

ジェネリクス対応のコードを書いても良かったのですが、めんどくさいのでPaymentTypeに対応したものだけを載せておきます。

extension PaymentType {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        /// Int -> PaymentType
        if let intValue = try? container.decode(Int.self) {
            if let rawValue = PaymentType(rawValue: intValue) {
                self = rawValue
                return
            }
            /// Int -> PaymentTypeに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot initialize PaymentType from invalid Int value \(intValue)"))
        }

        /// String -> Int -> PaymentType
        let stringValue = try container.decode(String.self)
        if let intValue = Int(stringValue) {
            if let rawValue = PaymentType(rawValue: intValue) {
                self = rawValue
                return
            }
            /// Int -> PaymentTypeに失敗
            throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot initialize PaymentType from invalid Int value \(intValue)"))
        }
        /// String -> Intに失敗
        throw DecodingError.typeMismatch(PaymentType.self, DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Cannot convert to Int from String value \(stringValue)"))
    }
}

extension KeyedEncodingContainer {
    mutating func encodeIfPresent(_ value: PaymentType?, forKey key: KeyedEncodingContainer<K>.Key) throws {
        switch value {
        case .some(let value):
            try encode(value, forKey: key)
        case .none:
            try encodeNil(forKey: key)
        }
    }
}

未知の値に対応する

未知の Enum の値に対応するためにはPaymentTypeに変更を加えるしかありません。

この先はまだちょっと長いので一旦ここまで。

価格
    えいむーさんは明日も頑張るよ © 2022