will and way

ただの自分用メモを人に伝える形式で書くことでわかりやすくまとめてるはずのブログ

DecodableのDecodeを簡潔に書きたい

Swift4でDecodableつかってますか〜?

公式にサポートしてもらえると本当にありがたいですよね。

しかしながら汎用的なパースをしようとすると若干、コードが冗長になります

Apple Developer Documentation

公式ドキュメントによると、カスタムなデコードの戦略を図る場合は下記のようにイニシャライザとデコードを実装する必要があります

extension Coordinate: Decodable {
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        latitude = try values.decode(Double.self, forKey: .latitude)
        longitude = try values.decode(Double.self, forKey: .longitude)
        
        let additionalInfo = try values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
        elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
    }
}

冗長ですね。値がnilや予期しない値だった場合のハンドリングが考慮されていないのでバギーです
これらを簡潔に書く軽いExtensionを作ってみたので、例をもとに説明してみたいと思います。

目指した要件は2点です

  1. パースが失敗したときのデフォルト値を設定する
  2. String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック

1. パースが失敗したときのデフォルト値を設定する

犬が受けたワクチンをリストするAPIJSONのレスポンスを例にしてみます。

Dog.json
[
  {
    "name": "Spring",
    "vaccinations": [
      {
        "name": "Rabies"
      },
      {
        "name": "Corona"
      }
    ]
  }
]

構造体

Dog.swift
// 予防接種の種類
struct Vaccination: Decodable {
    let name: String
}

struct Dog: Decodable {
    let name: String
    let vaccinations: [Vaccination]
}

例えば、予防接種を受けた数を表現するとき、受けたことがなかったら空の配列を作りたいとします。

しかし、空の配列は返さず、nullもしくはそもそもそのKey&Valueを返さないAPIなどもありえます。

Dog.json
[
  {
    "name": "Spring",
    "vaccinations": null    
  }
]

この場合、デコードエラーになるのでパースエラーのときにはvaccinationsに空配列をセットする実装を追加しましょう

Dog.swift
struct Dog: Decodable {
    let name: String
    let vaccinations: [Vaccination]

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

        do {
            vaccinations = try values.decode([Vaccination].self, forKey: .vaccinations)
        } catch {
            vaccinations = []
        }
    }

    private enum CodingKeys: String, CodingKey {
        case name
        case vaccinations
    }
}

問題点

  1. do - try - catchが冗長
  2. パラメータが増える度にdo - try - catchを実装する必要がある
  3. decodeの型を明示的に指定する必要がある

ということで

失敗時のデフォルト値を指定しつつ、型推論でよしなに型を指定してくれる実装をしたいと思います。

KeyedDecodingContainerを拡張する

decoder.container(keyedBy: CodingKeys.self)で取得できる型の拡張を書くのが手っ取り早いです

KeyedDecodingContainer+Helper.swift
extension KeyedDecodingContainer {
    func decode<ResultType: Decodable>(forKey key: Key, defaultValue: ResultType? = nil) -> ResultType? {
        do {
            return try decode(ResultType.self, forKey: key)
        } catch let error {
            NSLog(error)
            return defaultValue
        }
    }
}

これで5行が1行になりました

Dog.swift
struct Dog: Decodable {
    let name: String
    let vaccinations: [Vaccination]

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        name = try values.decode(String.self, forKey: .name)
        vaccinations = values.decode(forKey: .vaccinations) ?? []
    }

    private enum CodingKeys: String, CodingKey {
        case name
        case vaccinations
    }
}

2. String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック

次に犬の名前と年をパースする例を上げてみます。

Dog.json
[
  {
    "name": "Spring",
    "age": "10"
  }
]
Dog.swift
struct Dog: Decodable {
    let name: String
    let age: Int

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        name = try values.decode(String.self, forKey: .name)
        age = values.decode(forKey: .age) ?? 0
    }
}

ageがこれでは0になってしまいます。

ここで

  1. StringからIntにパースする
  2. それでも失敗したらデフォルト値にする

という要件をKeyedDecodingContainerのextensionで実装してみます

KeyedDecodingContainer+Helper.swift
extension KeyedDecodingContainer {
    func decodeWithString<ResultType: FallbackableResultType>(forKey key: Key, defaultValue: ResultType? = nil) -> ResultType? {
        do {
            return try decode(ResultType.self, forKey: key)
        } catch let error {
            NSLog(error)
            do {
                let string = try decode(String.self, forKey: key)
                return ResultType.toResultType(string: string, defaultValue: defaultValue)
            } catch let error {
                NSLog(error)
                return defaultValue
            }
        }
    }
}

internal protocol FallbackableType {
    static func toResultType(string: String, defaultValue: Self?) -> Self?
}

typealias FallbackableResultType = FallbackableType & Decodable

extension Int: FallbackableType {
    internal static func toResultType(string: String, defaultValue: Int?) -> Int? {
        return Int(string) ?? defaultValue
    }
}

これで、無事、デコードできるようになりました。

肝としては2点

  1. 最初に各型にデコードできるかトライ
    1. age: 10だった場合でも処理できるのでAPIを修正した場合にバグにならない
  2. FallbackableTypeを用意して、String→各型への変換処理を移譲している
    1. FallbackableTypeに準拠した型に制限していますので、Stringから該当する型に変換する戦略を書く必要があります。

まとめ

ということで、KeyedDecodingContainerを拡張すればデコード処理は簡潔に書ける〜という話でした