Conditionally skip codable properties depending on instance of JSONEncoder that's used

First, a brief explanation of the situation I'm facing in my app. I have model entities defined as Codable structs that match what's returned by a REST API, nothing fancy there. I also have a lightweight persistence layer in my app that just saves the JSON representations of these structs in files.

Recently I started needing to store additional bits of local-only state. The server's JSON parsing is not lenient, so I can't include any of those keys in the JSON I send to it (even if the value is null) otherwise I get an error. I already have a solution in place, but it's cumbersome and boilerplate-heavy.

What I'd like to do is to have a LocalOnly<T : Codable> : Codable property wrapper that's also codable, and would be skipped when encoding to JSON unless specific JSONEncoder instances are used (identified by a specific key in its userInfo).

That would be easy to detect in func encode(to encoder: Encoder), but by that point it is already too late and the key is already present in the JSON representation, I can only control its value. I then learned about the workaround of doing something like this to skip encoding a property:

public extension KeyedEncodingContainer {
    
    mutating func encode<T>(_ value: LocalOnly<T>, forKey key: K) throws {
        // Do nothing and the value is not encoded
    }
}

Unfortunately, there seems to be no way to get a reference to the Encoder instance from within this method. There's superEncoder(), but that will also always include an additional key in the JSON, and I'm not even sure if the Encoder returned by that preserves the same userInfo values.

Can anyone think of a way to do this? Or should I just give up?

That would be easy to detect in func encode(to encoder: Encoder) , but by that point it is already too late and the key is already present in the JSON representation, I can only control its value.

This is false. Before the encode(to:) method is called, none of your type's properties (neither their keys nor their values) are present in the JSON payload. Within your implementation of encode(to:), you have complete control over which, if any, of your type's properties are encoded to the JSON payload, as well as how they are encoded. That's the whole point of the method. For example, you could just leave the encode(to:) method completely empty and nothing (neither the keys nor the values) would be encoded to the JSON payload.

Here's an example of how you can use the userInfo dictionary of JSONEncoder to provide contextual information (e.g., whether or not to encode certain properties) during the encoding process:

import Foundation

struct Person: Codable {
    
    let name: String
    let age: Int
    
    let localOnlyProperty: Int

    func encode(to encoder: Encoder) throws {

        // If this method was empty, then none of the properties of
        // this type would be encoded to the JSON payload.

        var container = encoder.container(keyedBy: CodingKeys.self)
        
        // Here we are explicity encoding the `name` and `age` properties
        // to the JSON payload.
        try container.encode(self.name, forKey: .name)
        try container.encode(self.age, forKey: .age)

        // Retrieve the value for the `local` key that was set in the user info dictionary of the
        // `JSONEncoder` that is being used to encode an instance
        // of this type.
        let isLocal = (encoder.userInfo[.local] as? Bool) == true
        
        if isLocal {
            print("encoding localOnlyProperty to the JSON payload")
            try container.encode(
                self.localOnlyProperty,
                forKey: .localOnlyProperty
            )
        }
        else {
            print("NOT encoding localOnlyProperty to the JSON payload")
        }

    }

    enum CodingKeys: String, CodingKey {
        case name, age, localOnlyProperty
    }
}


extension CodingUserInfoKey {
    
    static let local = Self(rawValue: "local")!

}


let person = Person(name: "peter", age: 22, localOnlyProperty: 10)

let encoder = JSONEncoder()

// Here is where you decide whether or not to encode local-only properties.
//
// Set this key to `false` or don't include it at all in the dictionary in
// order to exclude local only properties.
encoder.userInfo[.local] = true


let data = try encoder.encode(person)
let dataString = String(data: data, encoding: .utf8)!
print("\n\(dataString)\n")

You could also create a subclass of JSONEncoder that sets the local key of the user info dictionary to true upon initialization:

class LocalJSONEncoder: JSONEncoder {
    
    override init() {
        super.init()
        self.userInfo[.local] = true
    }
    

}

I guess I wasn't clear enough. What I meant was that by the time encode(to encoder: Encoder) is called in a child entity, the parent entity has already encoded the key for it. The child entity cannot tell the parent to conditionally omit it.

I understand that by implementing encode(to encoder: Encoder) in the parent entity I have complete control over which child entities I encode or not. But what I wanted was to not have to implement encode(to encoder: Encoder) myself, but rather rely on the synthesized implementation, and have the property wrapper determine if its wrapped value should be encoded or not depending on the encoder that's being used. Something like this:

@propertyWrapper struct LocalOnly<T: Encodable>: Encodable {
    
    let wrappedValue: T
    
    func encode(to encoder: Encoder) throws {
        /* ... */
    }
}

struct Child: Encodable {
    
    let value: String
}

struct Parent: Encodable {
    
    let alwaysEncode: String
    @LocalOnly var conditionallyEncode: Child
}

let parent = Parent(alwaysEncode: "always", conditionallyEncode: Child(value: "conditional"))

let forGeneralUse = try JSONEncoder().encode(parent)
// conditionallyEncode should not be present here

let forLocalOnlyUse = try SomeCustomEncoder().encode(parent)
// conditionallyEncode *should* be present here

So, just to be clear, in the above example, you want to keep the default implementation of encode(to:) in Parent?

That's correct.

Why doesn't any of these work for you?

struct StuffFromServer: Codable {}
struct LocalStuff: Codable {
    var a: StuffFromServer
    var b: OtherStuff
}
class StuffFromServer: Codable {}
class LocalStuff: StuffFromServer {
    var a: OtherStuff
}

Also it's pretty hard to answer a question that we don't know. You should always post your whole problem instead of just your hypothetical solution to said problem.

It looks like what you're after is, in fact possible. See this library:

The key is to add a method to KeyedEncodingContainer that the default implementation of encode(to:) will call. What I couldn't figure out, however, is how to conditionally encode a property. The KeyedEncodingContainer doesn't have access to the user info dictionary.

Terms of Service

Privacy Policy

Cookie Policy