Search code examples
swiftdecoder

Decoding objects without knowing their type first


There is a likelihood this is an XY problem, I am open to these suggestions as well !

I am trying to work with Minecraft save data. Minecraft encodes Entities (basically anything that is not strictly a block) with their type inside an id property . The file then contains a big array of entities, which I want to decode and instantiate.

The problem is that, using Decodable, I must know an object's type before I start instantiating it like container.decode(Zombie.self). I can't figure out how to create a function that would read the id and return the right type of entity ?

I think this explains what I need better than any explanation could :

//Entity objects don't actually store their ID since re-encoding it is trivial.
protocol Entity : Decodable {var someProperty : Int {get set}}
struct Zombie : Entity {var someProperty : Int}
struct Skeleton : Entity {var someProperty : Int}

//Using JSON instead of SNBT so we can use JSONDecoder
let jsonData = """
[
    {
        "id":"zombie",
        "someProperty":"3"
    },
    {
        "id" : "skeleton",
        "someProperty":"3"
    }
]
"""

struct EntityList : Decodable {
    var list : [any Entity] = []
    init(from decoder : Decoder) throws {
        var container = try decoder.unkeyedContainer()
        //What should we put here ?
    }
}

let decoder = JSONDecoder()
let entityList = try decoder.decode(EntityList.self, from: Data(jsonData.utf8))
//entityList should be [Zombie, Skeleton]

At the moment I'm looking into the Factory pattern, maybe that's an interesting lead ? In any case, thank you for your help !


( Please note this question has nothing to do with decoding the actual binary contents of the file, it was honestly quite hard to do but I already have a working Encoder / Decoder. It is only about unpacking those contents, hence why I just used JSON in the example above, since we have a common Decoder for that. )


Solution

  • I honestly haven't used the new any syntax enough to know if that can help but I have done what you're trying to do numerous times and here is how I do it.

    Set up the data first

    We first declare what a Zombie and a Skeleton are. They could just inherit from a protocol or they could be separate structs...

    struct Zombie: Decodable {
      let someProperty: Int
    }
    
    struct Skeleton: Decodable {
      let someProperty: Int
      let skeletonSpecificProperty: String
    }
    

    Then we can turn your array of [anyEntityType] into a homogeneous array by using an enum and embedding the entities into it...

    enum Entity: Decodable {
      case zombie(Zombie)
      case skeleton(Skeleton)
    }
    

    Decode the enum given your JSON structure

    We have to provide a custom decoder for the Entity type...

    init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: RootKeys.self)
    
      // First get the `id` value from the JSON object
      let type = try container.decode(String.self, forKey: .id)
    
      // check the value for each type of entity we can decode
      switch type {
    
      // for each value of `id` create the related type
      case "zombie":
        let zombie = try Zombie(from: decoder)
        self = .zombie(zombie)
      case "skeleton":
        let skeleton = try Skeleton(from: decoder)
        self = .skeleton(skeleton)
      default:
        // throw an error here... unsupported type or something
      }
    }
    

    This should now let you decode an array of Entities from JSON into an [Entity] array.

    Deal with "unknown" types

    There is an extra step required for dealing with the "unknown" types. For instance, in the code above. If the JSON contains "id": "creeper" this will error as it can't deal with that. And you'll end up with your whole array failing to decode.

    I've created a couple of helper functions that help with that...

    If you create an object like...

    struct Minecraft: Decodable {
      let entities: [Entity]
    
      enum RootKeys: String, CodingKey {
        case entities
      }
    }
    

    And these helpers...

    extension KeyedDecodingContainer {
      func decodeAny<T: Decodable>(_ type: T.Type, forKey key: K) throws -> [T] {
        var items = try nestedUnkeyedContainer(forKey: key)
    
        var itemsArray: [T] = []
        while !items.isAtEnd {
          guard let item = try? items.decode(T.self) else {
            try items.skip()
            continue
          }
          itemsArray.append(item)
        }
        return itemsArray
      }
    }
    
    private struct Empty: Decodable { }
    
    extension UnkeyedDecodingContainer {
      mutating func skip() throws {
        _ = try decode(Empty.self)
      }
    }
    

    You can create a custom decoder for the Minecraft type like this...

    init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: RootKeys.self)
      self.entities = try container.decodeAny(Entity.self, forKey: .entities)
    }