Merge Encodable in Swift

Viewed 1495

I have the following Swift structs

struct Session: Encodable {
    let sessionId: String
}
struct Person: Encodable {
    let name: String
    let age: Int
}

let person = Person(name: "Jan", age: 36)
let session = Session(sessionId: "xyz")

that I need to encode to a json object that has this format:

{
  "name": "Jan",
  "age": 36,
  "sessionId": "xyz"
}

where all keys of the Session are merged into the keys of the Person

I thought about using a container struct with a custom Encodable implementation where I use a SingleValueEncodingContainer but it can obviously encode only one value

struct RequestModel: Encodable {
    let session: Session
    let person: Person

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(person)
        // crash
        try container.encode(session)
    }
}

let person = Person(name: "Jan", age: 36)
let session = Session(sessionId: "xyz")
let requestModel =  RequestModel(session: session, person: person)

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted

let data = try encoder.encode(requestModel)
let json = String(data: data, encoding: .utf8)!

print(json)

I cannot change the json format as it's a fixed network API. I could have the sessionId as property of the Person but I'd like to avoid that as they are unrelated models.

Another way could be to have the RequestModel copy all the properties from the Session and Person as follows but it's not very nice as my real structs have much more properties.

struct RequestModel: Encodable {
    let sessionId: String
    let name: String
    let age: Int

    init(session: Session, person: Person) {
        sessionId = session.sessionId
        name = person.name
        age = person.age
    }
}
3 Answers

Call encode(to:) of each encodable objects, instead of singleValueContainer(). It makes possible to join multi encodable objects into one encodable object without defining extra CodingKeys.

struct RequestModel: Encodable {
    let session: Session
    let person: Person

    public func encode(to encoder: Encoder) throws {
        try session.encode(to: encoder)
        try person.encode(to: encoder)
    }
}

Use encoder.container(keyedBy: CodingKeys.self) instead of singleValueContainer() and add the key-value pairs separately, i.e.

struct RequestModel: Encodable
{
    let session: Session
    let person: Person

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

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(person.age, forKey: RequestModel.CodingKeys.age)
        try container.encode(person.name, forKey: RequestModel.CodingKeys.name)
        try container.encode(session.sessionId, forKey: RequestModel.CodingKeys.sessionId)
    }
}

Output:

{
  "age" : 36,
  "name" : "Jan",
  "sessionId" : "xyz"
}

Let me know in case you still face any issues.

I'd like to expand on @marty-suzuki's answer here, because there are a few nuances that may be missed if you're not careful. Here's my version of the code:

struct EncodableCombiner: Encodable {
    let subelements: [Encodable]
    func encode(to encoder: Encoder) throws {
        for element in subelements {
            try element.encode(to: encoder)
        }
    }
}

Simply instantiate with an array of codable objects and treat the resulting object as a codable object in its own right. Now, there are a couple of important notes to remember when using this method:

  1. You can only have one type of root object in your JSON, which will either be a single value, an array, or a dictionary. So when you implement encode(to:) in your various codable objects, never create your container using encoder.singleValueContainer.
  2. Every object you wish to combine must operate on the same kind of container, so if one of them uses unkeyedContainer(), so must they all. Similarly, if one uses container(keyedBy:) then the others must too.
  3. If you're using keyed containers, then no two variables across all the combined objects can share the same key name! Otherwise, you're going to find that they overwrite each other since they're being parsed into the same dictionary.

An alternative that would alleviate these problems, but does not result in the same JSON structure, would be this:

struct EncodableCombiner: Encodable {
    let elementA: MyEncodableA
    let elementB: MyEncodableB
    func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()
        try container.encode(elementA)
        try container.encode(elementB)
    }
}

Now, this is slightly less convenient because we can't simply supply an array of objects that conform to Encodable; it needs to know exactly what they are to call container.encode(). The result is a JSON object with an array as its root object, and each subelement expressed as an element in that array. In fact, you could simplify it further like this:

struct EncodableCombiner: Encodable {
    let elementA: MyEncodableA
    let elementB: MyEncodableB
}

... which would of course result in a dictionary root object with the encoded form of MyEncodableA keyed as elementA, and MyEncodableB as elementB.

It all depends on what structure you want.

Related