jsonswiftproperty-wrapper

Swift Property Wrapper on Struct


I am struggling to create a swift struct, where I can use a property wrapper to decode some JSON that sometimes requires a value to conform to a specific type, but other times may become a dictionary with a store ID reference.

Here is an example struct:

struct JobDetail: Codable {
    var title: String
    var years: Int
}

struct Person: Codable {
    var name: String
    var age: Int
    var job: JobDetail
}

struct Employee: Codable {
    @UUIDEmbedded var info: Person
}

For example, in the Employee struct above, I can assign a unique ID to the info variable - I can also decide not to, in which case, the ID would just default to nil. In JSON form, it could look like either of the following...

{
  "info": {"id": "068D61D7-9BA9-4459-B328-1FF801F52264", "value": {"name": "John", "age": 30, "job": {"title": "CEO", "years": 5}}}
}

or

{
  "info": {"name": "John", "age": 30, "job": {"title": "CEO", "years": 5}}
}

While the code I currently have works great for this kind of thing and can automatically distinguish a "info" variable with an ID, from one without an ID, the problem occurs when I try to then use this property wrapper on anything other than a custom struct. For example, if I want to give the "age" variable in Person the @UUIDEmbedded property wrapper.

struct Person: Codable {
    var name: String              //works with UUIDEmbedded: ❌
    @UUIDEmbedded var age: Int    //works with UUIDEmbedded: ❌
    var job: JobDetail            //works with UUIDEmbedded: ✅
}

⚠️ Error: typeMismatch(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "info", intValue: nil), CodingKeys(stringValue: "value", intValue: nil), CodingKeys(stringValue: "age", intValue: nil)], debugDescription: "Expected to decode Dictionary<String, Any> but found number instead.", underlyingError: nil))

❌ Doesn't work for JSON:

{
  "info": {"name": "John", "age": 30, "job": {"title": "CEO", "years": 5}}
}

✅ Does work for JSON:

{
    "info": {"name": "John", "age": {"id": "884D61D7-9BA9-4459-B328-1FF801F52264", "value": 30}, "job": {"title": "CEO", "years": 5}}
}

It should be working for both.

Thank you for all of you help in advance! 😊

Current Code

Property Wrapper

@propertyWrapper
struct UUIDEmbedded<T: Codable>: Codable {
    private var value: T
    private var id: UUID?

    var wrappedValue: T {
        get { value }
        set { value = newValue }
    }

    var projectedValue: UUID? {
        get { id }
        set { id = newValue }
    }

    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        if container.contains(.id) {
            if let idString = try? container.decode(String.self, forKey: .id) {
                self.id = UUID(uuidString: idString)
            }
        }
        
        if container.contains(.value) {
            self.value = try container.decode(T.self, forKey: .value)
        } else {
            // If value key is not found, decode the entire container as the value
            self.value = try T(from: decoder)
        }
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        if let id = id {
            try container.encode(id, forKey: .id)
        }
        try container.encode(value, forKey: .value)
    }

    private enum CodingKeys: String, CodingKey {
        case id
        case value
    }
}

Decoding

let jsonStringWithoutID = """
{
  "info": {"name": "John", "age": 30, "job": {"title": "CEO", "years": 5}}
}
"""

do {
    let decoder = JSONDecoder()
    // Decoding JSON without ID
    let decodedStructWithoutID = try decoder.decode(Employee.self, from: Data(jsonStringWithoutID.utf8))
    print(decodedStructWithoutID.info.name) // Output: John
    if let uuid = decodedStructWithoutID.$info {
        print(uuid) // Output: nil
    } else {
        print("UUID is nil")
    }
    print(decodedStructWithoutID.info.age)
    print(decodedStructWithoutID.info.job.title)
} catch {
    print(error)
}

Solution

  • Use decoder.singleValueContainer() to determine if the value is a single fundamental type (String, Int, Boolean, etc.). Otherwise, catch custom structs and or @UUIDEmbedded key-value pairs using the container<CodingKey>(keyedBy type: CodingKey.Type) decoder method.

    Here is the code that needs to be implemented...

    do {
        let container = try decoder.singleValueContainer()
        self.value = try container.decode(T.self)
    } catch {
        //Original code here
    }
    

    Altogether, the decoder for the property wrapper would look like this...

    init(from decoder: Decoder) throws {
        do {
            let container = try decoder.singleValueContainer()
            self.value = try container.decode(T.self)
        } catch {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            
            if container.contains(.id) {
                if let idString = try? container.decode(String.self, forKey: .id) {
                    self.id = UUID(uuidString: idString)
                }
            }
            
            if container.contains(.value) {
                self.value = try container.decode(T.self, forKey: .value)
            } else {
                // If value key is not found, decode the entire container as the value
                self.value = try T(from: decoder)
            }
        }
    }