Search code examples
swiftgenericscodable

Omit generic optional with `JSONSerialiser`


When JSONSerialiser is used to encode an Encodable containing an Optional property, a nil value is omitted from encoding, potentially leading to an entirely empty (and nice and compact) structure. Here's an XCTestCase of this behaviour:

func test_whenNilValueIsSerialised_thenItIsOmitted() throws {

    struct Container: Codable {
        let cargo: Int?
    }

    let encoding = try XCTUnwrap(String(bytes: JSONEncoder().encode(Container(cargo: nil)), encoding: .utf8))
    XCTAssertEqual(encoding, "{}")
}

However, if Container is generic, and it is instantiated with the wrapped type as an optional, then this behaviour does not occur. The following test case fails:

func test_whenNilValueFromGenericIsSerialised_thenItIsOmitted() throws {

    struct Container<Cargo: Codable>: Codable {
        let cargo: Cargo
    }

    let container = Container<String?>(cargo: nil)
    let encoding = try XCTUnwrap(String(bytes: JSONEncoder().encode(container), encoding: .utf8))

    XCTAssertEqual(encoding, "{}")
}

The failure error is:

XCTAssertEqual failed: ("{"cargo":null}") is not equal to ("{}")

The Question: Is there are reasonably clean work around for this, perhaps by implementing an explicit encode(to:) for Container?


NB: In this case, the following solution is not appropriate:

struct Container<Cargo: Codable>: Codable {
    let cargo: Cargo?
}

… I want to be able to use a non optional as Cargo in many cases.


Solution

  • I think you would need to do something like in this question. You will need to check if cargo actually is an optional type, and get the wrapped value. Then, encodeIfPresent that unwrapped value.

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        let mirror = Mirror(reflecting: cargo)
        if mirror.displayStyle == .optional {
            let unwrapped = mirror.children.first?.value as? Encodable
            try container.encodeIfPresent(unwrapped.map(AnyEncodable.init), forKey: .cargo)
        } else {
            try container.encode(cargo, forKey: .cargo)
        }
    }
    
    struct AnyEncodable: Encodable {
        let wrapped: any Encodable
        func encode(to encoder: Encoder) throws {
            try wrapped.encode(to: encoder)
        }
    }
    

    Note that since the unwrapped value is of type Any?, you would need to cast to Encodable, and then wrap that in a concrete type (AnyEncodable).

    You can write this as an extension of KeyedEncodingContainer too:

    extension KeyedEncodingContainer {
        mutating func encodeGenericIfPresent<T: Encodable>(_ x: T, forKey key: Key) throws {
            let mirror = Mirror(reflecting: x)
            if mirror.displayStyle == .optional {
                let unwrapped = mirror.children.first?.value as? Encodable
                try encodeIfPresent(unwrapped.map(AnyEncodable.init), forKey: key)
            } else {
                try encode(x, forKey: key)
            }
        }
    }
    

    If you encode a nested optional, where the outer optional is not nil, but the inner optional is nil,

    JSONEncoder().encode(Container(cargo: String??.some(.none))
    

    It would still produce {"cargo": null}. This is consistent with the synthesised encode implementations in non-generic cases like this:

    struct Container: Codable {
        var cargo: String??
    }