swiftencodable

JSONEncoder encodes nil values when dealing with generic types


Summary: JSONEncoder unexpectedly encodes nil values as null when working with generic types that wrap over a protocol.

Here's a short code that reproduces the problem:

protocol Event {
    associatedtype Contents: Encodable
    var name: String { get }
    var contents: Contents { get }
}

struct ClickEvent: Event, Encodable {
    let name: String = "click"
    let contents: Int? = nil
}

struct EventWrapper<T: Event>: Encodable {
    let name: String
    let data: T.Contents

    init(event: T) {
        name = event.name
        data = event.contents
    }
}

let encoder = JSONEncoder()
let event = ClickEvent()
let wrapper = EventWrapper(event: event)

let encodedEventData = try! encoder.encode(event)
print(String(data: encodedEventData, encoding: .utf8)!)
// prints: {"name":"click"}

let encodedWrapperData = try! encoder.encode(wrapper)
print(String(data: encodedWrapperData, encoding: .utf8)!)
// prints: {"name":"click","data":null}

The problem here is the fact that the encoder encodes the nil value instead of skipping it. And it looks this is caused by the associated type of the Event protocol.

How can I avoid this? Note that I cannot change the type hierarchy, as the actual code is part of a broader codebase. And the null value is rejected by the backend, so I really need to get rid of it.

The JSONEncoder class doesn't have any configuration options for nil values. Also, writing custom encode(to:) methods is not possible since generics are involved. Are there any other options to fix this problem? Re-architecting the app or changing the backend code are unfortunately not feasible options...


Solution

  • It's not actually impossible to manually write custom encode methods in this case, since you just need to check if a "generic thing" is nil.

    Using this answer, you can write:

    enum CodingKeys: String, CodingKey {
        case name, data
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        if case Optional<Any>.none = data as Any {
            return
        }
        try container.encode(data, forKey: .data)
    }
    

    Alternatively, as I have learned here, if there are too many properties to encode, you can even write your own KeyedEncodingContainer extension:

    extension KeyedEncodingContainer {
        public mutating func encode<T: Encodable>(_ value: T, forKey key: Key) throws {
            if case Optional<Any>.none = value as Any {} else {
                // even though value will not be nil at this point, we still use encodeIfPresent
                // because calling encode will cause infinite recursion
                try encodeIfPresent(value, forKey: key)
            }
        }
    }
    

    This "magically" works. The generated encode implementation in EventWrapper will resolve to this new implementation you wrote.