未知の値に対応した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
が入るパターンは次の二つが考えられます。
- キーが存在しない場合
- 値が
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 を与えるとそれぞれ、
null
の場合Expected String but found null value instead.
- キーが存在しない場合
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
に変更を加えるしかありません。
この先はまだちょっと長いので一旦ここまで。