Use Swift's Encodable to encode optional properties as null without custom encoding

Viewed 8742

I want to encode an optional field with Swift's JSONEncoderusing a struct that conforms to the Encodable protocol.

The default setting is that JSONEncoder uses the encodeIfPresent method, which means that values that are nil are excluded from the Json.

How can I override this for a single property without writing my custom encode(to encoder: Encoder) function, in which I have to implement the encoding for all properties (like this article suggests under "Custom Encoding" )?

Example:

struct MyStruct: Encodable {
    let id: Int
    let date: Date?
}

let myStruct = MyStruct(id: 10, date: nil)
let jsonData = try JSONEncoder().encode(myStruct)
print(String(data: jsonData, encoding: .utf8)!) // {"id":10}
3 Answers
import Foundation

enum EncodableOptional<Wrapped>: ExpressibleByNilLiteral {
    case none
    case some(Wrapped)
    init(nilLiteral: ()) {
        self = .none
    }
}

extension EncodableOptional: Encodable where Wrapped: Encodable {

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

extension EncodableOptional{

    var value: Optional<Wrapped> {

        get {
            switch self {
            case .none:
                return .none
            case .some(let v):
                return .some(v)
            }
        }

        set {
            switch newValue {
            case .none:
                self = .none
            case .some(let v):
                self = .some(v)
            }
        }
    }
}

struct User: Encodable {
    var name: String
    var surname: String
    var age: Int?
    var gender: EncodableOptional<String>
}

func main() {
    var user = User(name: "William", surname: "Lowson", age: 36, gender: nil)
    user.gender.value = "male"
    user.gender.value = nil
    print(user.gender.value ?? "")
    let jsonEncoder = JSONEncoder()
    let data = try! jsonEncoder.encode(user)
    let json = try! JSONSerialization.jsonObject(with: data, options: [])
    print(json)

    let dict: [String: Any?] = [
        "gender": nil
    ]
    let d = try! JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted])
    let j = try! JSONSerialization.jsonObject(with: d, options: [])
    print(j)
}

main()

This will give you output after executing main:

{
    age = 36;
    gender = "<null>";
    name = William;
    surname = Lowson;
}
{
    gender = "<null>";
}

So, you can see that we encoded gender as it'll be null in dictionary. The only limitation you'll get is that you'll have to access optional value via value property

You can use something like this to encode single values.

struct CustomBody: Codable {
    let method: String
    let params: [Param]

    enum CodingKeys: String, CodingKey {
        case method = "method"
        case params = "params"
    }
}

enum Param: Codable {
    case bool(Bool)
    case integer(Int)
    case string(String)
    case stringArray([String])
    case valueNil
    case unsignedInteger(UInt)
    case optionalString(String?)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let x = try? container.decode(Bool.self) {
            self = .bool(x)
            return
        }
        if let x = try? container.decode(Int.self) {
            self = .integer(x)
            return
        }
        if let x = try? container.decode([String].self) {
              self = .stringArray(x)
              return
          }
        if let x = try? container.decode(String.self) {
            self = .string(x)
            return
        }
        if let x = try? container.decode(UInt.self) {
            self = .unsignedInteger(x)
            return
        }
        throw DecodingError.typeMismatch(Param.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for Param"))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .bool(let x):
            try container.encode(x)
        case .integer(let x):
            try container.encode(x)
        case .string(let x):
            try container.encode(x)
        case .stringArray(let x):
            try container.encode(x)
        case .valueNil:
            try container.encodeNil()
        case .unsignedInteger(let x):
            try container.encode(x)
        case .optionalString(let x):
            x?.isEmpty == true ? try container.encodeNil() : try container.encode(x)
        }
    }
}

And the usage is like this

RequestBody.CustomBody(method: "WSDocMgmt.getDocumentsInContentCategoryBySearchSource", 
                       params: [.string(legacyToken), .string(shelfId), .bool(true), .valueNil, .stringArray(queryFrom(filters: filters ?? [])), .optionalString(sortMethodParameters()), .bool(sortMethodAscending()), .unsignedInteger(segment ?? 0), .unsignedInteger(segmentSize ?? 0), .string("NO_PATRON_STATUS")])

If you try to decode this JSON your trusty JSONDecoder will create exactly the same object as exemplified in this Playground:

import Cocoa

struct MyStruct: Codable {
    let id: Int
    let date: Date?
}

let jsonDataWithNull = """
    {
        "id": 8,
        "date":null
    }
    """.data(using: .utf8)!

let jsonDataWithoutDate = """
    {
        "id": 8
    }
    """.data(using: .utf8)!

do {
    let withNull = try JSONDecoder().decode(MyStruct.self, from: jsonDataWithNull)
    print(withNull)
} catch {
    print(error)
}

do {
    let withoutDate = try JSONDecoder().decode(MyStruct.self, from: jsonDataWithoutDate)
    print(withoutDate)
} catch {
    print(error)
}

This will print

MyStruct(id: 8, date: nil)
MyStruct(id: 8, date: nil)

so from a "standard" Swift point of view your distinction makes very little sense. You can of course determine it, but the path is thorny and leads through the purgatory of JSONSerialization or [String:Any] decoding and a lot more ugly optionals. Of course if you are serving another language with your interface that might make sense, but still I consider it a rather rare case which easily merits the implementation of encode(to encoder: Encoder) which is not hard at all, just a little tedious to clarify your slightly non-standard behaviour.

This looks like a fair compromise to me.

Related