How to use Swift JSONDecode with dynamic types?
Asked Answered
C

5

9

My App has a local cache and sends/receives models from/to the server. So I decided to build a map [String : Codable.Type], essentially to be able to decode anything I have on this generic cache either created locally or received from server.

let encoder = JSONEncoder()
let decoder = JSONDecoder()
var modelNameToType = [String : Codable.Type]()
modelNameToType = ["ContactModel": ContactModel.Self, "AnythingModel" : AnythingModel.Self, ...] 

Whatever I create on the App I can encode successfully and store on cache like this:

let contact = ContactModel(name: "John")
let data = try! encoder.encode(contact)
CRUD.shared.storekey(key: "ContactModel$10", contact)

I would like to decode like this:

let result = try! decoder.decode(modelNameToType["ContactModel"]!, from: data)

But I get the error:

Cannot invoke 'decode' with an argument list of type (Codable.Type, from: Data)

What am I doing wrong? Any help is appreciated

Fixing the type works, and solves any local request, but not a remote request.

let result = try! decoder.decode(ContactModel.self, from: data)

Contact Model:

struct ContactModel: Codable {
    var name : String
}

For remote requests I would have a function like this:

    func buildAnswer(keys: [String]) -> Data {

        var result = [String:Codable]()
        for key in keys {
            let data = CRUD.shared.restoreKey(key: key)
            let item = try decoder.decode(modelNameToType[key]!, from: data)
            result[key] = item
        }
        return try encoder.encode(result)
    }

...if I solve the decode issue. Any help appreciated.

Cheapjack answered 24/11, 2017 at 11:18 Comment(6)
How this "decoder.decode(ContactModel.self, from: data)" doesn't work still ? This one looks correct. Please also post your ContactModel class definition please.Zerline
I was just stating that decoder.decode(ContactModel.self, from: data) does work!Cheapjack
When you say "dynamic", what type do you expect result to be? (It has to be a specific type at compile-time. It can't be "whatever the type works out to be at runtime." If it were "some random type not known at compile-time" what methods could you call on it? In other words, what would be the next line of code that used result?)Bahr
This app has a local cache and every so often receives and sends info to the server. I will update the question to have the whole func as you suggestedCheapjack
I understand it is kind of inverted, confusing, usually the server does the answering, and the app does the questioning, but in this case I have both situations.Cheapjack
This is tightly related to this questionPrimordial
U
12

The Codable API is built around encoding from and decoding into concrete types. However, the round-tripping you want here shouldn't have to know about any concrete types; it's merely concatenating heterogenous JSON values into a JSON object.

Therefore, JSONSerialization is a better tool for the job in this case, as it deals with Any:

import Foundation

// I would consider lifting your String keys into their own type btw.
func buildAnswer(keys: [String]) throws -> Data {

  var result = [String: Any](minimumCapacity: keys.count)

  for key in keys {
    let data = CRUD.shared.restoreKey(key: key)
    result[key] = try JSONSerialization.jsonObject(with: data)
  }
  return try JSONSerialization.data(withJSONObject: result)
}

That being said, you could still make this with JSONDecoder/JSONEncoder – however it requires quite a bit of type-erasing boilerplate.

For example, we need a wrapper type that conforms to Encodable, as Encodable doesn't conform to itself:

import Foundation

struct AnyCodable : Encodable {

  private let _encode: (Encoder) throws -> Void

  let base: Codable
  let codableType: AnyCodableType

  init<Base : Codable>(_ base: Base) {
    self.base = base
    self._encode = {
      var container = $0.singleValueContainer()
      try container.encode(base)
    }
    self.codableType = AnyCodableType(type(of: base))
  }

  func encode(to encoder: Encoder) throws {
    try _encode(encoder)
  }
}

We also need a wrapper to capture a concrete type that can be used for decoding:

struct AnyCodableType {

  private let _decodeJSON: (JSONDecoder, Data) throws -> AnyCodable
  // repeat for other decoders...
  // (unfortunately I don't believe there's an easy way to make this generic)
  //

  let base: Codable.Type

  init<Base : Codable>(_ base: Base.Type) {
    self.base = base
    self._decodeJSON = { decoder, data in
      AnyCodable(try decoder.decode(base, from: data))
    }
  }

  func decode(from decoder: JSONDecoder, data: Data) throws -> AnyCodable {
    return try _decodeJSON(decoder, data)
  }
}

We cannot simply pass a Decodable.Type to JSONDecoder's

func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T

as when T is a protocol type, the type: parameter takes a .Protocol metatype, not a .Type metatype (see this Q&A for more info).

We can now define a type for our keys, with a modelType property that returns an AnyCodableType that we can use for decoding JSON:

enum ModelName : String {

  case contactModel = "ContactModel"
  case anythingModel = "AnythingModel"

  var modelType: AnyCodableType {
    switch self {
    case .contactModel:
      return AnyCodableType(ContactModel.self)
    case .anythingModel:
      return AnyCodableType(AnythingModel.self)
    }
  }
}

and then do something like this for the round-tripping:

func buildAnswer(keys: [ModelName]) throws -> Data {

  let decoder = JSONDecoder()
  let encoder = JSONEncoder()

  var result = [String: AnyCodable](minimumCapacity: keys.count)

  for key in keys {
    let rawValue = key.rawValue
    let data = CRUD.shared.restoreKey(key: rawValue)
    result[rawValue] = try key.modelType.decode(from: decoder, data: data)
  }
  return try encoder.encode(result)
}

This probably could be designed better to work with Codable rather than against it (perhaps a struct to represent the JSON object you send to the server, and use key paths to interact with the caching layer), but without knowing more about CRUD.shared and how you use it; it's hard to say.

Unskillful answered 26/11, 2017 at 13:26 Comment(1)
Thank you Hamish. Nicely understood and answered. I didn't see this language behaviour coming, but I confess that I don't have as much experience with Swift as with other languages.Cheapjack
S
1

I would like to decode like this:

let result = try! decoder.decode(modelNameToType["ContactModel"]!, from: data)

But I get the error:

Cannot invoke 'decode' with an argument list of type (Codable.Type, from: Data)

You are using decode incorrectly. The first parameter to decoder.decode must not be an object; it must be a type. You cannot pass a metatype wrapped up in an expression.

You can, however, pass an object and take its type. So you could solve this with a generic that guarantees that we are a Decodable adopter. Here's a minimal example:

func testing<T:Decodable>(_ t:T, _ data:Data) {
    let result = try! JSONDecoder().decode(type(of:t), from: data)
    // ...
}

If you pass a ContactModel instance as the first parameter, that's legal.

Swayne answered 24/11, 2017 at 20:2 Comment(12)
Revised my answer to suggest using a generic and type(of:).Swayne
Hey matt, in that case I would need to store an object on the dictionary [string:any]. But when I do type(of: dictionary[key]) I get Any? as result.Cheapjack
@RodrigoFava So unwrap it! type(of: dictionary[key]!) (Except you should unwrap safely.) The type of an Optional Any is Optional Any. But unwrap the Optional, you've got an Any, and now type(of:) takes the underlying type correctly.Swayne
Except of course you really should unwrap it safely, as I explain here: https://mcmap.net/q/93020/-determining-if-swift-dictionary-contains-key-and-obtaining-any-of-its-valuesSwayne
Let's say I have entry ["contact": ContactModel.Self], type(of: dictionary["contact]!) is yielding Any.Type, not ContactModel.TypeCheapjack
You have misunderstood my answer. I say supply a ContactModel instance, not a type. Read the last sentence of my answer. Read it carefully. Aloud.Swayne
let d = ["string":"anotherString"]; print(type(of:d["string"]!)) yields String. And that is so even if d is typed as [String:Any]. Your move.Swayne
For every model type we will have the expense of +1 instance ... Can it be done with a closure?Cheapjack
The point is only that you can’t do what you originally proposed to do. The language doesn’t permit it. Thus you need an alternative. I proposed one. Hamish proposed one. Pick one. Or neither.Swayne
I did implement my alternative, as well as I am taking the chance to learn more about the language. No offenses meant, and thank you anyway. I will give you the credit for the answer.Cheapjack
There is little reason to pass type(of:) to JSONDecoder.decode or other generic functions. It is common practice to add a T.Type parameter to generic functions to allow callers to specify a generic type (specializing f<T>() -> T in the obvious way by writing f<String>() is invalid), but this parameter is almost always ignored in the implementation. JSONDecoder().decode(type(of:t), from: data) and JSONDecoder().decode(T.self, from: data) decode in exactly the same way.Autochthon
See the implementation of JSONDecoder for how exactly this type parameter is used.Autochthon
A
1

Here is a solution similar to @Hamish's AnyCodable solution which requires less work but only works for classes.

typealias DynamicCodable = AnyObject & Codable

extension Decodable {
    static func decode<K: CodingKey>(from container: KeyedDecodingContainer<K>, forKey key: K) throws -> Self {
        try container.decode(Self.self, forKey: key)
    }
}
extension Encodable {
    func encode<K: CodingKey>(to container: inout KeyedEncodingContainer<K>, forKey key: K) throws {
        try container.encode(self, forKey: key)
    }
}

struct AnyDynamicCodable: Codable {
    let value: DynamicCodable
    
    init(_ value: DynamicCodable) {
        self.value = value
    }
    
    enum CodingKeys: String, CodingKey {
        case type
        case value
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let typeName = try container.decode(String.self, forKey: .type)
        guard let type = NSClassFromString(typeName) as? DynamicCodable.Type else {
            throw DecodingError.typeMismatch(DynamicCodable.Type.self, .init(codingPath: decoder.codingPath + [CodingKeys.type], debugDescription: "NSClassFromString returned nil or did not conform to DynamicCodable.", underlyingError: nil))
        }
        self.value = try type.decode(from: container, forKey: .value)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        let typeName: String = NSStringFromClass(type(of: self.value))
        try container.encode(typeName, forKey: .type)
        try self.value.encode(to: &container, forKey: .value)
    }
}

This relies on a few features.

First, if a value is an object, then it's type can be converted to a String with NSStringFromClass and recovered later with NSClassFromString. Honestly, I am not sure how safe this is, but this approach seems to work for me in the limited testing I have done. I have asked about this in a separate question here. This lets us encode and decode the type of an object.

Once we have a type, we would like to be able to decode a value of this type like so:

let value = try container.decode(type, forKey .value)

but this is not possible. The reason is that KeyedDecodingContainer.decode is a generic function whose type argument must be known at compile time. This leads us to

extension Decodable {
    static func decode<K: CodingKey>(from container: KeyedDecodingContainer<K>, forKey key: K) throws -> Self {
        try container.decode(Self.self, forKey: key)
    }
}

which then enables us to write

let value = try type.decode(from: container, forKey: .value)

instead.

This is a very general technique. If you need to plug a dynamic (runtime) type into a generic function, wrap it in an extension on the type constraints of the generic function. Unfortunately, you will not be able to use to wrap a method which takes Any because you cannot extend Any. In this case, the type argument to KeyedDecodingContainer.decode is a Decodable, so we wrap this method in an extension to Decodable. We repeat the same process for Encodable and KeyedEncodingContainer.encode to get encoding functionality.

Now, [String: AnyDynamicCodable] conforms to Codable.

If you want to use this approach but with structs, consider using a lightweight class wrapper such as

final class DynamicCodableWrapper<T: Codable>: Codable {
    let value: T
    init(_ value: T) {
        self.value = value
    }
}

You may even want to build this in to your AnyDynamicCodable type to make some kind of AnyCodable type.

Autochthon answered 22/6, 2021 at 22:0 Comment(0)
C
0

I believe this is the solution you are looking for.

import Foundation

struct ContactModel: Codable {
    let name: String
}

let encoder = JSONEncoder()
let decoder = JSONDecoder()

var map = [String: Codable]()

map["contact"] = ContactModel(name: "John")
let data = try! encoder.encode(map["contact"] as! ContactModel)

let result = try! decoder.decode(ContactModel.self, from: data)

debugPrint(result)

This will print the following.

ContactModel(name: "John")
Comstock answered 24/11, 2017 at 19:23 Comment(1)
Astralis, I will update my question to see if I can make the case more clearCheapjack
C
0

One approach you can consider is defining two different structs, each with a different data type for the field that changes. If the first decode fails, then try decoding with the second data type like this:

struct MyDataType1: Decodable {
    let user_id: String
}

struct MyDataType2: Decodable {
    let user_id: Int
}

do {
    let myDataStruct = try JSONDecoder().decode(MyDataType1.self, from: jsonData)

} catch let error {
    // look at error here to verify it is a type mismatch
    // then try decoding again with type MyDataType2
}
Coppola answered 26/10, 2018 at 15:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.