【Swift】JSONEncoderでnilをNullとしてエンコードする

# 記事の背景
先日、API にリクエストを投げる際、`Int?` 型に nil を入れて投げたところ、うまく反映されないという問
題にぶち当たった。

## 背景の詳細説明
上記の説明だと少しわかりづらいと思い、サンプルコードを使って説明する。
ぜひ、Swift Playground などで試して欲しい。


以下のような JSON データがあったとする。

```JSON:JSON
{
  "name" : "Ame",
  "age" : 18,
  "hobby" : "web制作"
}
```

この場合、Swift の構造体から JSON データに変換するためには対象の構造体が `Encodable` プロトコルまたは `Codable` プロトコルに準拠している必要がある。

そのため、以下のような構造体を作成する。

```swift:SampleEncode.swift
struct Person: Encodable {
    var name:String
    var age:Int?
    var hobby:String?
}
```

ここで、`age` と `hobby` は Null を許容するものとした。

これらを基に、Swift で JSON データにエンコードする。JSON データをエンコードするには `JSONEncoder` を使用する必要がある。
そのため、以下のようなコードを作成する。

```diff_swift:SampleEncode.swift
struct Person: Encodable {
    var name:String
    var age:Int?
    var hobby:String?
}

+ let person = Person(name: "Ame", age: 21, hobby: "Watch TV")
+
+ let encoder = JSONEncoder()
+
+ // フォーマットを指定
+ encoder.outputFormatting = .prettyPrinted
+
+ // エンコード
+ let jsonData = try encoder.+ encode(person)
+
+ // 文字コードUTF8のData型に変換
+ print(String(data: jsonData , encoding: .utf8)!)
```

これを実行すると、以下の JSON 形式のコードが出力される。

```json:出力
{
  "name" : "Ame",
  "age" : 21,
  "hobby" : "Watch TV"
}
```

ここまではよくあるエンコードの話。

次に、`age` と `hobby` に `nil` を入れてリクエストを投げてみる。

```diff_swift:SampleEncode.swif
import Foundation

struct Person: Codable {
    var name:String
    var age:Int?
    var hobby:String?
}

- let person = Person(name: "Ame", age: 21, hobby: "Watch TV")
+ let person = Person(name: "Ame", age: nil, hobby: nil)

let encoder = JSONEncoder()

// フォーマットを指定
encoder.outputFormatting = .prettyPrinted

// エンコード
let jsonData = try encoder.encode(person)

// 文字コードUTF8のData型に変換
print(String(data: jsonData , encoding: .utf8)!)
```

ここで、理想的な出力は、以下のようになる。

```json:理想的な出力
{
  "name" : "Ame",
  "age" : null,
  "hobby" : null
}
```
しかし実行すると、以下のように出力される。

```json:実際の出力
{
  "name" : "Ame"
}
```

このように、`nil` を入れた箇所だけ空白で出力される。

この実際の出力をどうにかして理想的な出力の形にしたいというのが今回の記事の目的である。

# 本論
あえていきなり結論を言うと、`JSONEncoder` がキーを保持し、`null` をセットするように設定する方法しかない。

自動生成されたものは使えないため、自分で `encode(to:)` の実装を書く必要がある。

明示的にフィールド内をエンコード処理させると、以下のようなコードになる。

```diff_swift:SampleEncode.swift

import Foundation

struct Person: Codable {
    var name:String
    var age:Int?
    var hobby:String?

+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(name, forKey: .name)
+        try container.encode(age, forKey: .age)
+        try container.encode(hobby, forKey: .hobby)
+    }
}

let person = Person(name: "Ame", age: nil, hobby: nil)

let encoder = JSONEncoder()

// フォーマットを指定
encoder.outputFormatting = .prettyPrinted

// エンコード
let jsonData = try encoder.encode(person)

// 文字コードUTF8のData型に変換
print(String(data: jsonData , encoding: .utf8)!)
```

```json:出力結果
{
  "name" : "Ame",
  "age" : null,
  "hobby" : null
}
```

しかし、これだと他の箇所でも同じ内容のコードを追加することになり、不便なので、プロパティラッパーを作成した方が便利そうではある。

よって、以下のようなコードを作成する。(ただし、Swift5.1 以上が必要)

```swift:EncodeProperty.swift
@propertyWrapper
struct NullEncodable<T>: Encodable where T: Encodable {

    var wrappedValue: T?

    init(wrappedValue: T?) {
        self.wrappedValue = wrappedValue
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch wrappedValue {
        case .some(let value): try container.encode(value)
        case .none: try container.encodeNil()
        }
    }
}
```

コードは比較的単純で、エンコードをする値があればそのままエンコード、なければ `Null` 値をエンコードしている。

## 使い方
プロパティがラップされた構造体は、プロパティの直前に属性を指定する必要がある。今回は `@NullEncodable` を `Null` 値が必要なプロパティ(`age`, `hobby`)の直前に指定する必要がある。

```diff_swift:SampleEncode.swif
import Foundation

struct Person: Codable {
    var name:String
-    var age:Int?
-    var hobby:String?
+   @NullEncoda var age:Int?
+   @NullEncoda var hobby:String?
}

+@propertyWrapper
+struct NullEncodable<T>: Encodable where T: Encodable {
+
+    var wrappedValue: T?
+
+    init(wrappedValue: T?) {
+        self.wrappedValue = wrappedValue
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.singleValueContainer()
+        switch wrappedValue {
+        case .some(let value): try container.encode(value)
+        case .none: try container.encodeNil()
+        }
+    }
+}


 let person = Person(name: "Ame", age: nil, hobby: nil)

let encoder = JSONEncoder()

// フォーマットを指定
encoder.outputFormatting = .prettyPrinted

// エンコード
let jsonData = try encoder.encode(person)

// 文字コードUTF8のData型に変換
print(String(data: jsonData , encoding: .utf8)!)
```

```json:出力
{
  "name" : "Ame",
  "age" : null,
  "hobby" : null
}
```

これで理想的なリクエストを送ることができた。

# 原因の考察
[Apple、FoundationフレームワークのGitHubのとあるIssue](https://github.com/apple/swift-corelibs-foundation/issues/3594)によると、以下 3 つが挙げられる。

1. `nil` を `Null` でエンコードするオプションは `encode(to:)` で自分で書くことができるから、Swift やフレームワークが用意する必要性はない
1. Null と欠落を同一視する API が多いため
1. ペイロードは小さい方が良いから

以上の理由からこのような仕様になっていると考えられる。

# まとめ
Swift5 では API にリクエストを投げる際、`Int?` や `String?` 型に `nil` を入れてもラベルが省略され、空欄となる。

そのため、`JSONEncoder` で `NUll` を入れるよう設定する必要があった。
# 参考文献

https://stackoverflow.com/questions/47266862/encode-nil-value-as-null-with-jsonencoder


https://yagamo-style.com/2022/03/12/json-with-null-value/